This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this
push:
new 07d74006d7c [airflow-ctl/v0-1-test] Stop airflowctl sending an old
token to a new API URL (#76287) (#76475)
07d74006d7c is described below
commit 07d74006d7c74384f5982f6c6e8be25a736ae93c
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Nov 4 20:10:17 2026 +0200
[airflow-ctl/v0-1-test] Stop airflowctl sending an old token to a new API
URL (#76287) (#76475)
(cherry picked from commit 596dd41485c86982abe14b9a8c8638600500dc81)
Co-authored-by: Andrew Chang <[email protected]>
---
airflow-ctl/docs/cli-and-env-variables-ref.rst | 1 +
airflow-ctl/docs/howto/index.rst | 1 +
airflow-ctl/src/airflowctl/api/client.py | 29 ++++++--
.../src/airflowctl/ctl/commands/auth_command.py | 3 +-
airflow-ctl/tests/airflow_ctl/api/test_client.py | 86 +++++++++++++++++++++-
.../airflow_ctl/ctl/commands/test_auth_command.py | 17 +++++
6 files changed, 127 insertions(+), 10 deletions(-)
diff --git a/airflow-ctl/docs/cli-and-env-variables-ref.rst
b/airflow-ctl/docs/cli-and-env-variables-ref.rst
index 2df9424260b..3c4f9a755d2 100644
--- a/airflow-ctl/docs/cli-and-env-variables-ref.rst
+++ b/airflow-ctl/docs/cli-and-env-variables-ref.rst
@@ -67,6 +67,7 @@ Environment Variables
This variable can be used to enable debug mode for the CLI.
It disables some features such as keyring integration and save credentials
to file.
+ A login in debug mode does not use the keyring, so log in again after you
turn debug mode off.
It is only meant to use if either you are developing airflowctl or running
API integration tests.
Please do not use this variable unless you know what you are doing.
diff --git a/airflow-ctl/docs/howto/index.rst b/airflow-ctl/docs/howto/index.rst
index 51e4ebc42a5..339247cf95b 100644
--- a/airflow-ctl/docs/howto/index.rst
+++ b/airflow-ctl/docs/howto/index.rst
@@ -113,6 +113,7 @@ This parameter is useful when you want to manage multiple
Airflow environments.
**--skip-keyring**: This parameter is optional.
Useful when there's no keyring available in the system where airflowctl is
running.
Set ``AIRFLOW_CLI_TOKEN`` or use the ``--api-token`` flag for next commands.
+Later commands do not read a token from the keyring for this environment, so a
token from an earlier login is not used.
More Usage and Help Pictures
''''''''''''''''''''''''''''
diff --git a/airflow-ctl/src/airflowctl/api/client.py
b/airflow-ctl/src/airflowctl/api/client.py
index 7aa6de6e8df..aea909e7cb8 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -198,6 +198,10 @@ def _read_api_url(path: str) -> str:
return api_url
+def _is_token_in_keyring(path: str) -> bool:
+ return _read_cli_config(path).get("token_in_keyring") is not False
+
+
# Credentials for the API
class Credentials:
"""Credentials for the API."""
@@ -239,20 +243,27 @@ class Credentials:
"""
Save the credentials to keyring and URL to disk as a file.
- Skip saving the token to keyring if skip_keyring is True, in this case,
- only the config file with the API URL is created.
+ Skip saving the token to keyring if skip_keyring is True.
+
+ The debug credentials file of an earlier login is removed first, and
the
+ config file records that the token is not in keyring until
+ keyring.set_password succeeds. So a later command never reads a token
+ that this login did not store.
"""
default_config_dir = os.environ.get("AIRFLOW_HOME",
os.path.expanduser("~/airflow"))
os.makedirs(default_config_dir, exist_ok=True)
config_path = _safe_path_under_airflow_home(default_config_dir,
self.input_cli_config_file)
+ debug_mode = os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true"
+ debug_path = _safe_path_under_airflow_home(
+ default_config_dir, f"debug_creds_{self.input_cli_config_file}"
+ )
+ with contextlib.suppress(FileNotFoundError):
+ os.remove(debug_path)
with open(config_path, "w") as f:
- json.dump({"api_url": self.api_url}, f)
+ json.dump({"api_url": self.api_url, "token_in_keyring": False}, f)
try:
- if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
- debug_path = _safe_path_under_airflow_home(
- default_config_dir,
f"debug_creds_{self.input_cli_config_file}"
- )
+ if debug_mode:
with open(debug_path, "w") as f:
json.dump({self.token_key_for_environment(self.api_environment):
self.api_token}, f)
else:
@@ -274,6 +285,8 @@ class Credentials:
self.token_key_for_environment(self.api_environment),
self.api_token,
)
+ with open(config_path, "w") as f:
+ json.dump({"api_url": self.api_url}, f)
except (NoKeyringError, NotImplementedError) as e:
log.error(e)
raise AirflowCtlKeyringException(
@@ -310,7 +323,7 @@ class Credentials:
"Set AIRFLOW_CLI_DEBUG_MODE=false or log in with
debug mode enabled first."
) from e
self.api_token = None
- else:
+ elif _is_token_in_keyring(config_path):
try:
self.api_token = keyring.get_password(
"airflowctl",
self.token_key_for_environment(self.api_environment)
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index 9cbe48e9f81..ee2a8512de7 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -32,6 +32,7 @@ from airflowctl.api.client import (
NEW_API_CLIENT,
ClientKind,
Credentials,
+ _is_token_in_keyring,
_read_api_url,
get_default_api_url,
provide_api_client,
@@ -206,7 +207,7 @@ def list_envs(args) -> None:
debug_creds = json.load(f)
if Credentials.token_key_for_environment(env_name) in
debug_creds:
token_status = "authenticated"
- else:
+ elif _is_token_in_keyring(config_path):
# Check keyring
token = keyring.get_password("airflowctl",
Credentials.token_key_for_environment(env_name))
if token:
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index 3e5f79f8e05..aa5120c04d6 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -25,15 +25,17 @@ import sys
import tempfile
from types import SimpleNamespace
from typing import Any
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, create_autospec, patch
from urllib.parse import urlparse
import httpx
+import keyring
import pytest
import structlog.testing
import tenacity
import time_machine
from httpx import URL
+from keyring.errors import KeyringLocked
from tenacity import stop_after_attempt, wait_none
from airflowctl.api.client import (
@@ -414,10 +416,92 @@ class TestCredentials:
credentials = Credentials(client_kind=cli_client,
api_token="TEST_TOKEN").load()
assert json.load(f) == {
"api_url": credentials.api_url,
+ "token_in_keyring": False,
}
mock_keyring.set_password.assert_not_called()
mock_keyring.get_password.assert_not_called()
+ @pytest.fixture
+ def keyring_store(self, monkeypatch, tmp_path):
+ monkeypatch.setenv("AIRFLOW_HOME", str(tmp_path))
+ stored: dict[tuple[str, str], str] = {}
+ mock_keyring = create_autospec(keyring)
+ mock_keyring.set_password.side_effect = lambda service, username,
token: stored.update(
+ {(service, username): token}
+ )
+ mock_keyring.get_password.side_effect = lambda service, username:
stored.get((service, username))
+ monkeypatch.setattr("airflowctl.api.client.keyring", mock_keyring)
+ return mock_keyring
+
+ def test_save_skip_keyring_does_not_reuse_token_of_earlier_login(self,
keyring_store):
+ Credentials(api_url="http://old:8080", api_token="OLD_TOKEN",
api_environment="prod").save()
+ Credentials(api_url="http://new:8080", api_token="NEW_TOKEN",
api_environment="prod").save(
+ skip_keyring=True
+ )
+
+ credentials = Credentials(client_kind=ClientKind.CLI,
api_environment="prod").load()
+ assert (credentials.api_url, credentials.api_token) ==
("http://new:8080", None)
+
+ def test_save_in_debug_mode_does_not_reuse_token_of_earlier_login(self,
keyring_store, monkeypatch):
+ Credentials(api_url="http://old:8080", api_token="OLD_TOKEN",
api_environment="prod").save()
+ monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+ Credentials(api_url="http://new:8080", api_token="NEW_TOKEN",
api_environment="prod").save()
+ monkeypatch.delenv("AIRFLOW_CLI_DEBUG_MODE")
+
+ credentials = Credentials(client_kind=ClientKind.CLI,
api_environment="prod").load()
+ assert (credentials.api_url, credentials.api_token) ==
("http://new:8080", None)
+
+ def
test_save_in_debug_mode_failing_to_write_token_does_not_reuse_debug_token_of_earlier_login(
+ self, keyring_store, monkeypatch
+ ):
+ monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+ Credentials(api_url="http://old:8080", api_token="OLD_TOKEN",
api_environment="prod").save()
+
+ def open_failing_for_debug_file(file, mode="r", *args, **kwargs):
+ if os.path.basename(file) == "debug_creds_prod.json" and mode ==
"w":
+ raise PermissionError(file)
+ return open(file, mode, *args, **kwargs)
+
+ monkeypatch.setattr("airflowctl.api.client.open",
open_failing_for_debug_file, raising=False)
+ with pytest.raises(PermissionError):
+ Credentials(api_url="http://new:8080", api_token="NEW_TOKEN",
api_environment="prod").save()
+ monkeypatch.delattr("airflowctl.api.client.open")
+
+ with pytest.raises(AirflowCtlCredentialNotFoundException, match="Debug
credentials file not found"):
+ Credentials(client_kind=ClientKind.CLI,
api_environment="prod").load()
+
+ @pytest.mark.parametrize("skip_keyring", [False, True])
+ def
test_save_outside_debug_mode_does_not_reuse_debug_token_of_earlier_login(
+ self, keyring_store, skip_keyring, monkeypatch
+ ):
+ monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+ Credentials(api_url="http://old:8080", api_token="OLD_TOKEN",
api_environment="prod").save()
+ monkeypatch.delenv("AIRFLOW_CLI_DEBUG_MODE")
+ Credentials(api_url="http://new:8080", api_token="NEW_TOKEN",
api_environment="prod").save(
+ skip_keyring=skip_keyring
+ )
+ monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+
+ with pytest.raises(AirflowCtlCredentialNotFoundException, match="Debug
credentials file not found"):
+ Credentials(client_kind=ClientKind.CLI,
api_environment="prod").load()
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ pytest.param(ValueError("Incorrect Password"),
id="wrong-keyring-password"),
+ pytest.param(KeyringLocked("Can't store password on keychain"),
id="locked-keychain"),
+ ],
+ )
+ def
test_save_failing_to_store_token_does_not_reuse_token_of_earlier_login(self,
keyring_store, error):
+ Credentials(api_url="http://old:8080", api_token="OLD_TOKEN",
api_environment="prod").save()
+ keyring_store.set_password.side_effect = error
+
+ with pytest.raises(type(error)):
+ Credentials(api_url="http://new:8080", api_token="NEW_TOKEN",
api_environment="prod").save()
+
+ credentials = Credentials(client_kind=ClientKind.CLI,
api_environment="prod").load()
+ assert (credentials.api_url, credentials.api_token) ==
("http://new:8080", None)
+
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_LOAD"})
@patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
@patch("airflowctl.api.client.keyring")
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 d7331e6975b..2856394ac40 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
@@ -568,6 +568,23 @@ class TestListEnvs:
assert env["api_url"] == "error reading config"
assert expected in env["status"]
+ @patch("airflowctl.ctl.commands.auth_command.AirflowConsole",
autospec=True)
+ @patch("keyring.get_password", autospec=True, return_value="OLD_TOKEN")
+ def test_list_envs_skips_keyring_when_token_is_not_in_keyring(
+ self, mock_get_password, mock_console, monkeypatch, tmp_path
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", str(tmp_path))
+ (tmp_path / "production.json").write_text(
+ json.dumps({"api_url": "http://new:8080", "token_in_keyring":
False})
+ )
+
+ auth_command.list_envs(self.parser.parse_args(["auth", "list-envs"]))
+
+ assert mock_console.return_value.print_as.call_args.kwargs["data"] == [
+ {"environment": "production", "api_url": "http://new:8080",
"status": "not authenticated"}
+ ]
+ mock_get_password.assert_not_called()
+
def test_list_envs_debug_mode(self, monkeypatch):
"""Test list-envs in debug mode."""
with tempfile.TemporaryDirectory() as temp_airflow_home: