Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions awxkit/awxkit/cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion awxkit/awxkit/cli/docs/source/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions awxkit/awxkit/cli/docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
19 changes: 15 additions & 4 deletions awxkit/awxkit/cli/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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',
Expand All @@ -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',
Expand Down
77 changes: 77 additions & 0 deletions awxkit/test/cli/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
67 changes: 67 additions & 0 deletions awxkit/test/cli/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading