diff --git a/awxkit/awxkit/cli/client.py b/awxkit/awxkit/cli/client.py index c06188c123..4e6dc74107 100755 --- a/awxkit/awxkit/cli/client.py +++ b/awxkit/awxkit/cli/client.py @@ -82,7 +82,38 @@ class CLI(object): return '--help' in self.argv or '-h' in self.argv def authenticate(self): - """Configure the current session for basic auth""" + """Configure the current session for authentication. + + Uses Basic authentication when AWXKIT_FORCE_BASIC_AUTH environment variable + is set to true, otherwise defaults to session-based authentication. + + For AAP Gateway environments, set AWXKIT_FORCE_BASIC_AUTH=true to bypass + session login restrictions. + """ + # Check if Basic auth is forced via environment variable + if config.get('force_basic_auth', False): + config.use_sessions = False + + # Validate credentials are provided + username = self.get_config('username') + password = self.get_config('password') + + if not username or not password: + raise ValueError( + "Basic authentication requires both username and password. " + "Provide --conf.username and --conf.password or set " + "CONTROLLER_USERNAME and CONTROLLER_PASSWORD environment variables." + ) + + # Apply Basic auth credentials to the session + try: + self.root.connection.login(username, password) + self.root.get() + except Exception as e: + raise RuntimeError(f"Basic authentication failed: {str(e)}. " "Verify credentials and network connectivity.") from e + return + + # Use session-based authentication (default) config.use_sessions = True self.root.load_session().get() diff --git a/awxkit/awxkit/config.py b/awxkit/awxkit/config.py index 7a973d3535..6a48338805 100644 --- a/awxkit/awxkit/config.py +++ b/awxkit/awxkit/config.py @@ -32,6 +32,7 @@ config.assume_untrusted = config.get('assume_untrusted', True) config.client_connection_attempts = int(os.getenv('AWXKIT_CLIENT_CONNECTION_ATTEMPTS', 5)) config.prevent_teardown = to_bool(os.getenv('AWXKIT_PREVENT_TEARDOWN', False)) config.use_sessions = to_bool(os.getenv('AWXKIT_SESSIONS', False)) +config.force_basic_auth = to_bool(os.getenv('AWXKIT_FORCE_BASIC_AUTH', False)) config.api_base_path = os.getenv('CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX', '/api/') config.api_base_path = os.getenv('AWXKIT_API_BASE_PATH', config.api_base_path) config.gateway_base_path = os.getenv('AWXKIT_GATEWAY_BASE_PATH', '/api/gateway/') diff --git a/awxkit/test/cli/test_authentication.py b/awxkit/test/cli/test_authentication.py new file mode 100644 index 0000000000..f196f66043 --- /dev/null +++ b/awxkit/test/cli/test_authentication.py @@ -0,0 +1,103 @@ +import pytest +from typing import Tuple, List, Optional +from unittest.mock import Mock + +from awxkit.cli import CLI +from awxkit import config + + +@pytest.fixture(autouse=True) +def reset_config_state(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure clean config state for each test to prevent parallel test interference""" + monkeypatch.setattr(config, 'force_basic_auth', False, raising=False) + monkeypatch.setattr(config, 'use_sessions', False, raising=False) + + +# ============================================================================ +# Test Helper Functions +# ============================================================================ + + +def setup_basic_auth(cli_args: Optional[List[str]] = None) -> Tuple[CLI, Mock, Mock]: + """Set up CLI with mocked connection for Basic auth testing""" + cli = CLI() + cli.parse_args(cli_args or ['awx', '--conf.username', 'testuser', '--conf.password', 'testpass']) + + mock_root = Mock() + mock_connection = Mock() + mock_root.connection = mock_connection + cli.root = mock_root + + return cli, mock_root, mock_connection + + +def setup_session_auth(cli_args: Optional[List[str]] = None) -> Tuple[CLI, Mock, Mock]: + """Set up CLI with mocked session for Session auth testing""" + cli = CLI() + cli.parse_args(cli_args or ['awx', '--conf.username', 'testuser', '--conf.password', 'testpass']) + + mock_root = Mock() + mock_load_session = Mock() + mock_root.load_session.return_value = mock_load_session + cli.root = mock_root + + return cli, mock_root, mock_load_session + + +def test_basic_auth_enabled(monkeypatch): + """Test that AWXKIT_FORCE_BASIC_AUTH=true enables Basic authentication""" + cli, mock_root, mock_connection = setup_basic_auth() + monkeypatch.setattr(config, 'force_basic_auth', True) + cli.authenticate() + + mock_connection.login.assert_called_once_with('testuser', 'testpass') + mock_root.get.assert_called_once() + assert not config.use_sessions + + +def test_session_auth_default(monkeypatch): + """Test that session auth is used by default (backward compatibility)""" + cli, mock_root, mock_load_session = setup_session_auth() + monkeypatch.setattr(config, 'force_basic_auth', False) + cli.authenticate() + + mock_root.load_session.assert_called_once() + mock_load_session.get.assert_called_once() + assert config.use_sessions + + +def test_aap_gateway_scenario(monkeypatch): + """Test the specific AAP Gateway scenario from AAP-46830""" + cli, mock_root, mock_connection = setup_basic_auth( + ['awx', '--conf.host', 'https://aap-sbx.cambiahealth.com', '--conf.username', 'puretest', '--conf.password', 'testpass'] + ) + monkeypatch.setattr(config, 'force_basic_auth', True) + cli.authenticate() + + mock_connection.login.assert_called_once_with('puretest', 'testpass') + mock_root.get.assert_called_once() + assert not config.use_sessions + + +def test_empty_credentials_error(monkeypatch): + """Test error handling for explicitly empty credentials""" + cli, mock_root, mock_connection = setup_basic_auth(['awx', '--conf.username', '', '--conf.password', '']) + monkeypatch.setattr(config, 'force_basic_auth', True) + + with pytest.raises(ValueError, match="Basic authentication requires both username and password"): + cli.authenticate() + + mock_connection.login.assert_not_called() + + +def test_connection_failure(monkeypatch): + """Test error handling when Basic auth connection fails""" + cli, mock_root, mock_connection = setup_basic_auth() + mock_connection.login.side_effect = Exception("Connection failed") + monkeypatch.setattr(config, 'force_basic_auth', True) + + with pytest.raises(RuntimeError, match="Basic authentication failed: Connection failed"): + cli.authenticate() + + mock_connection.login.assert_called_once_with('testuser', 'testpass') + assert not config.use_sessions