diff --git a/awxkit/awxkit/cli/client.py b/awxkit/awxkit/cli/client.py index 1e15442ab8e9..373423061cba 100755 --- a/awxkit/awxkit/cli/client.py +++ b/awxkit/awxkit/cli/client.py @@ -130,12 +130,21 @@ def _is_main_help_request(self): def authenticate(self): """Configure the current session for authentication. + If an OAuth2 token is provided (via --conf.token or CONTROLLER_OAUTH_TOKEN), + it is sent as a Bearer token in the Authorization header. + 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 an OAuth2 token is provided + token = self.get_config('token') + if token: + self.root.connection.session.headers['Authorization'] = f'Bearer {token}' + return + # Check if Basic auth is forced via environment variable if config.get('force_basic_auth', False): config.use_sessions = False diff --git a/awxkit/awxkit/cli/docs/source/authentication.rst b/awxkit/awxkit/cli/docs/source/authentication.rst index ec1ee0329be6..3818cf9dbec9 100644 --- a/awxkit/awxkit/cli/docs/source/authentication.rst +++ b/awxkit/awxkit/cli/docs/source/authentication.rst @@ -3,7 +3,24 @@ Authentication ============== -To authenticate to AWX, include your username and password in each command invocation as shown in the following examples: +OAuth2 Token Authentication +--------------------------- + +If your AWX account uses social authentication (e.g., GitHub, SAML, OIDC) and you do not have a local password, you can authenticate using an OAuth2 personal access token. + +Create a token in the AWX UI (User → Tokens → Add), then provide it using one of the following methods: + +.. code:: bash + + CONTROLLER_OAUTH_TOKEN=my_token awx jobs list + awx --conf.token my_token jobs list + +Token authentication takes precedence over username/password when both are provided. + +Username and Password Authentication +------------------------------------ + +To authenticate with a username and password: .. code:: bash diff --git a/awxkit/awxkit/cli/docs/source/usage.rst b/awxkit/awxkit/cli/docs/source/usage.rst index 68741cb0a5fb..4427e99bc987 100644 --- a/awxkit/awxkit/cli/docs/source/usage.rst +++ b/awxkit/awxkit/cli/docs/source/usage.rst @@ -95,3 +95,6 @@ A few of the most important ones are: ``--conf.password, CONTROLLER_PASSWORD`` the AWX password to use for authentication + +``--conf.token, CONTROLLER_OAUTH_TOKEN`` + an OAuth2 personal access token for authentication (overrides username/password) diff --git a/awxkit/awxkit/cli/format.py b/awxkit/awxkit/cli/format.py index 5e81e3295c57..9c921aade390 100644 --- a/awxkit/awxkit/cli/format.py +++ b/awxkit/awxkit/cli/format.py @@ -25,18 +25,23 @@ def strtobool(val): def get_config_credentials(): - """Load username and password from config.credentials.default. + """Load username, password, and token from config.credentials.default. In order to respect configurations from AWXKIT_CREDENTIAL_FILE. """ default_username = 'admin' default_password = 'password' + default_token = None if not hasattr(config, 'credentials'): - return default_username, default_password + return default_username, default_password, default_token default = config.credentials.get('default', {}) - return (default.get('username', default_username), default.get('password', default_password)) + return ( + default.get('username', default_username), + default.get('password', default_password), + default.get('token', default_token), + ) def add_authentication_arguments(parser, env): @@ -47,7 +52,7 @@ def add_authentication_arguments(parser, env): metavar='https://example.awx.org', ) - config_username, config_password = get_config_credentials() + config_username, config_password, config_token = get_config_credentials() # options configured via cli args take higher precedence than those from the config auth.add_argument( '--conf.username', @@ -59,6 +64,12 @@ def add_authentication_arguments(parser, env): default=env.get('CONTROLLER_PASSWORD', env.get('TOWER_PASSWORD', config_password)), metavar='TEXT', ) + auth.add_argument( + "--conf.token", + default=env.get("CONTROLLER_OAUTH_TOKEN", env.get("TOWER_OAUTH_TOKEN", config_token)), + help="OAuth2 token for authentication (overrides username/password)", + metavar="TEXT", + ) auth.add_argument( '-k', diff --git a/awxkit/test/cli/test_authentication.py b/awxkit/test/cli/test_authentication.py index f196f660436c..cc3e44236d6e 100644 --- a/awxkit/test/cli/test_authentication.py +++ b/awxkit/test/cli/test_authentication.py @@ -44,6 +44,21 @@ def setup_session_auth(cli_args: Optional[List[str]] = None) -> Tuple[CLI, Mock, return cli, mock_root, mock_load_session +def setup_token_auth(cli_args: Optional[List[str]] = None) -> Tuple[CLI, Mock, Mock]: + """Set up CLI with mocked connection for OAuth2 token auth testing""" + cli = CLI() + cli.parse_args(cli_args or ['awx', '--conf.token', 'my_oauth_token']) + + mock_root = Mock() + mock_connection = Mock() + mock_connection.session.headers = {} + mock_root.connection = mock_connection + mock_root.load_session.return_value = mock_root + cli.root = mock_root + + return cli, mock_root, mock_connection + + def test_basic_auth_enabled(monkeypatch): """Test that AWXKIT_FORCE_BASIC_AUTH=true enables Basic authentication""" cli, mock_root, mock_connection = setup_basic_auth() @@ -101,3 +116,65 @@ def test_connection_failure(monkeypatch): mock_connection.login.assert_called_once_with('testuser', 'testpass') assert not config.use_sessions + + +def test_oauth_token_auth(): + """Test that providing an OAuth2 token sets the Bearer header""" + cli, mock_root, mock_connection = setup_token_auth() + cli.authenticate() + + assert mock_connection.session.headers['Authorization'] == 'Bearer my_oauth_token' + mock_connection.login.assert_not_called() + mock_root.load_session.assert_not_called() + + +def test_oauth_token_from_cli_flag(): + """Test that --conf.token CLI flag sets Bearer auth""" + cli, mock_root, mock_connection = setup_token_auth(['awx', '--conf.token', 'cli_token_value']) + cli.authenticate() + + assert mock_connection.session.headers['Authorization'] == 'Bearer cli_token_value' + mock_connection.login.assert_not_called() + + +def test_oauth_token_from_env_var(): + """Test that CONTROLLER_OAUTH_TOKEN env var sets Bearer auth""" + cli = CLI() + cli.parse_args(['awx'], env={'CONTROLLER_OAUTH_TOKEN': 'env_token_value'}) + + mock_root = Mock() + mock_connection = Mock() + mock_connection.session.headers = {} + mock_root.connection = mock_connection + cli.root = mock_root + + cli.authenticate() + + assert mock_connection.session.headers['Authorization'] == 'Bearer env_token_value' + mock_connection.login.assert_not_called() + + +def test_oauth_token_precedence_over_basic_auth(monkeypatch): + """Test that OAuth2 token takes precedence over Basic auth""" + cli, mock_root, mock_connection = setup_token_auth(['awx', '--conf.token', 'my_token', '--conf.username', 'user', '--conf.password', 'pass']) + monkeypatch.setattr(config, 'force_basic_auth', True) + cli.authenticate() + + assert mock_connection.session.headers['Authorization'] == 'Bearer my_token' + mock_connection.login.assert_not_called() + + +def test_empty_token_falls_through_to_session_auth(): + """Test that an empty token falls through to session-based auth""" + cli = CLI() + cli.parse_args(['awx', '--conf.token', '', '--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 + + cli.authenticate() + + mock_root.load_session.assert_called_once() + mock_load_session.get.assert_called_once() diff --git a/awxkit/test/cli/test_config.py b/awxkit/test/cli/test_config.py index 2a7ade16ece2..e6895f5b0429 100644 --- a/awxkit/test/cli/test_config.py +++ b/awxkit/test/cli/test_config.py @@ -108,6 +108,73 @@ def test_config_file(): assert config.credentials.default.password == 'secret' +def test_oauth_token_from_controller_env(): + """Test CONTROLLER_OAUTH_TOKEN is picked up from environment""" + cli = CLI() + cli.parse_args(["awx"], env={"CONTROLLER_OAUTH_TOKEN": "my_token"}) + assert cli.get_config("token") == "my_token" + + +def test_oauth_token_from_tower_env(): + """Test TOWER_OAUTH_TOKEN fallback is picked up from environment""" + cli = CLI() + cli.parse_args(["awx"], env={"TOWER_OAUTH_TOKEN": "tower_token"}) + assert cli.get_config("token") == "tower_token" + + +def test_oauth_token_controller_takes_precedence_over_tower(): + """Test CONTROLLER_OAUTH_TOKEN takes precedence over TOWER_OAUTH_TOKEN""" + cli = CLI() + cli.parse_args( + ["awx"], + env={ + "CONTROLLER_OAUTH_TOKEN": "controller_token", + "TOWER_OAUTH_TOKEN": "tower_token", + }, + ) + assert cli.get_config("token") == "controller_token" + + +def test_oauth_token_cli_overrides_env(): + """Test --conf.token CLI flag overrides CONTROLLER_OAUTH_TOKEN env var""" + cli = CLI() + cli.parse_args( + ["awx", "--conf.token", "cli_token"], + env={"CONTROLLER_OAUTH_TOKEN": "env_token"}, + ) + assert cli.get_config("token") == "cli_token" + + +def test_oauth_token_default_none(): + """Test that token defaults to None when not provided""" + cli = CLI() + cli.parse_args(["awx"], env={}) + assert cli.get_config("token") is None + + +def test_oauth_token_from_credential_file(monkeypatch): + """Test that token is read from AWXKIT_CREDENTIAL_FILE.""" + monkeypatch.setattr(config, 'credentials', {'default': {'username': 'mary', 'password': 'secret', 'token': 'file_token'}}) + + cli = CLI() + cli.parse_args(['awx'], env={}) + assert cli.get_config('token') == 'file_token' + + +def test_oauth_token_env_overrides_credential_file(monkeypatch): + """Test that CONTROLLER_OAUTH_TOKEN overrides token from credential file.""" + monkeypatch.setattr(config, 'credentials', {'default': {'token': 'file_token'}}) + + cli = CLI() + cli.parse_args( + ['awx'], + env={ + 'CONTROLLER_OAUTH_TOKEN': 'env_token', + }, + ) + assert cli.get_config('token') == 'env_token' + + def test_controller_optional_api_urlpattern_prefix(): """Tests that CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX is honored when set.""" cli = CLI()