From c8981e321efc73b0c5329022d091cd3dab72429e Mon Sep 17 00:00:00 2001 From: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:18:25 -0400 Subject: [PATCH] Make aap_token functional for collection token auth (#16498) The aap_token parameter was added to the collection argspec and docs in #16025, but nothing consumed it after token auth was removed in #15623: modules silently ignored the token and fell back to basic auth, breaking token authentication through the AAP gateway. Wire it up so requests authenticate with the provided token (e.g. one issued by the AAP gateway, which validates it and proxies to the controller): - Send "Authorization: Bearer " in make_request when aap_token is set, skipping the basic-auth login probe; basic auth is unchanged when no token is given - Accept the token as a string or as the dict set as a fact by the ansible.platform.token module ({token: ..., id: ...}), which is the documented cross-collection mint/use/delete workflow - Restore controller_oauthtoken and tower_oauthtoken as aliases for back-compat with pre-#15623 playbooks, matching downstream - Forward aap_token through the controller_api lookup and controller inventory plugins via short_params, and add the missing CONTROLLER_OAUTH_TOKEN/TOWER_OAUTH_TOKEN env sources to the plugin doc fragment (plugins resolve env vars from doc fragments, not env_fallback); AAP_TOKEN is no longer marked deprecated there - Support tokens in the awxkit-based export/import modules - Add unit tests covering the Bearer header for both token forms, the aliases, the bad-dict failure, and the basic-auth fallback Verified end-to-end against a live gateway-fronted AAP 2.7 deployment: modules, the lookup plugin, both aliases, all env sources, dict-form tokens, job launch/wait, and a clean HTTP 401 on an invalid token. Co-Authored-By: Claude Fable 5 --- awx_collection/plugins/doc_fragments/auth.py | 6 +- .../plugins/doc_fragments/auth_plugin.py | 14 ++- awx_collection/plugins/module_utils/awxkit.py | 5 +- .../plugins/module_utils/controller_api.py | 34 +++++- awx_collection/test/awx/test_token_auth.py | 103 ++++++++++++++++++ 5 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 awx_collection/test/awx/test_token_auth.py diff --git a/awx_collection/plugins/doc_fragments/auth.py b/awx_collection/plugins/doc_fragments/auth.py index 1fcde939df..272ca0da67 100644 --- a/awx_collection/plugins/doc_fragments/auth.py +++ b/awx_collection/plugins/doc_fragments/auth.py @@ -34,13 +34,15 @@ options: aliases: [ tower_password , aap_password ] aap_token: description: - - The OAuth token to use. + - The OAuth token to use, sent as a Bearer token in the Authorization header. + - When connecting through the AAP gateway, use a token issued by the gateway. - This value can be in one of two formats. - A string which is the token itself. (i.e. bqV5txm97wqJqtkxlMkhQz0pKhRMMX) - - A dictionary structure as returned by the token module. + - A dictionary structure as set as a fact by the M(ansible.platform.token) module. - If value not set, will try environment variable C(CONTROLLER_OAUTH_TOKEN) and then config files type: raw version_added: "3.7.0" + aliases: [ controller_oauthtoken, tower_oauthtoken ] validate_certs: description: - Whether to allow insecure connections to AWX. diff --git a/awx_collection/plugins/doc_fragments/auth_plugin.py b/awx_collection/plugins/doc_fragments/auth_plugin.py index 15b78f949f..846659d7a4 100644 --- a/awx_collection/plugins/doc_fragments/auth_plugin.py +++ b/awx_collection/plugins/doc_fragments/auth_plugin.py @@ -42,13 +42,23 @@ options: alternatives: 'TOWER_PASSWORD, AAP_PASSWORD' aap_token: description: - - The OAuth token to use. + - The OAuth token to use, sent as a Bearer token in the Authorization header. + - When connecting through the AAP gateway, use a token issued by the gateway. env: - - name: AAP_TOKEN + - name: CONTROLLER_OAUTH_TOKEN deprecated: collection_name: 'awx.awx' version: '4.0.0' why: Collection name change + alternatives: 'AAP_TOKEN' + - name: TOWER_OAUTH_TOKEN + deprecated: + collection_name: 'awx.awx' + version: '4.0.0' + why: Collection name change + alternatives: 'AAP_TOKEN' + - name: AAP_TOKEN + aliases: [ controller_oauthtoken, tower_oauthtoken ] verify_ssl: description: - Specify whether Ansible should verify the SSL certificate of the controller host. diff --git a/awx_collection/plugins/module_utils/awxkit.py b/awx_collection/plugins/module_utils/awxkit.py index 5029d44526..b296f6a2df 100644 --- a/awx_collection/plugins/module_utils/awxkit.py +++ b/awx_collection/plugins/module_utils/awxkit.py @@ -34,7 +34,10 @@ class ControllerAWXKitModule(ControllerModule): def authenticate(self): try: - self.connection.login(username=self.username, password=self.password) + if self.aap_token: + self.connection.session.headers['Authorization'] = 'Bearer {0}'.format(self.aap_token) + else: + self.connection.login(username=self.username, password=self.password) self.authenticated = True except Exception: self.fail_json("Failed to authenticate") diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 8d033f9afb..2a2d4604ac 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -99,6 +99,7 @@ class ControllerModule(AnsibleModule): aap_token=dict( type='raw', no_log=True, + aliases=['controller_oauthtoken', 'tower_oauthtoken'], required=False, fallback=(env_fallback, ['CONTROLLER_OAUTH_TOKEN', 'TOWER_OAUTH_TOKEN', 'AAP_TOKEN']) ), @@ -118,10 +119,12 @@ class ControllerModule(AnsibleModule): 'request_timeout': 'request_timeout', 'max_retries': 'max_retries', 'retry_backoff_factor': 'retry_backoff_factor', + 'aap_token': 'aap_token', } host = '127.0.0.1' username = None password = None + aap_token = None verify_ssl = True request_timeout = 10 max_retries = 5 @@ -160,6 +163,8 @@ class ControllerModule(AnsibleModule): if direct_value is not None: setattr(self, short_param, direct_value) + self._parse_aap_token() + # Perform some basic validation if not self.host.startswith(("https://", "http://")): # NOSONAR self.host = "https://{0}".format(self.host) @@ -186,6 +191,15 @@ class ControllerModule(AnsibleModule): except Exception as e: self.fail_json(msg="Unable to resolve controller_host ({1}): {0}".format(self.url.hostname, e)) + def _parse_aap_token(self): + # aap_token can be the token string itself, or the dict that the + # ansible.platform.token module sets as the aap_token fact + if isinstance(self.aap_token, dict): + if 'token' in self.aap_token: + self.aap_token = self.aap_token['token'] + else: + self.fail_json(msg="The provided dict in aap_token did not properly contain the token entry") + def build_url(self, endpoint, query_params=None, app_key=None): # Make sure we start with /api/vX if not endpoint.startswith("/"): @@ -572,12 +586,7 @@ class ControllerAPIModule(ControllerModule): # Extract the headers, this will be used in a couple of places headers = kwargs.get('headers', {}) - # Authenticate to AWX (if not already done so) - if not self.authenticated: - # This method will set a cookie in the cookie jar for us - self.authenticate(**kwargs) - - headers['Authorization'] = self._get_basic_authorization_header() + headers['Authorization'] = self._get_authorization_header(**kwargs) if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') @@ -761,6 +770,19 @@ class ControllerAPIModule(ControllerModule): return prefix + def _get_authorization_header(self, **kwargs): + if self.aap_token: + # A token (e.g. issued by the AAP gateway) is validated by the server on + # every request, so no login round-trip is needed + return 'Bearer {0}'.format(self.aap_token) + + # Authenticate to AWX (if not already done so) + if not self.authenticated: + # This method will set a cookie in the cookie jar for us + self.authenticate(**kwargs) + + return self._get_basic_authorization_header() + def _get_basic_authorization_header(self): basic_credentials = b64encode("{0}:{1}".format(self.username, self.password).encode()).decode() return "Basic {0}".format(basic_credentials) diff --git a/awx_collection/test/awx/test_token_auth.py b/awx_collection/test/awx/test_token_auth.py new file mode 100644 index 0000000000..5400d6f7a5 --- /dev/null +++ b/awx_collection/test/awx/test_token_auth.py @@ -0,0 +1,103 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json + +import pytest + +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes +from requests.models import Response +from unittest import mock + + +def getheader(self, header_name, default): + return default + + +def read(self): + return json.dumps({}) + + +def status(self): + return 200 + + +def make_recorder(): + """Build a mock for Request.open that records every call made through it.""" + calls = [] + + def opener(self, method, url, **kwargs): + calls.append({'method': method, 'url': url, 'headers': kwargs.get('headers') or {}}) + r = Response() + r.getheader = getheader.__get__(r) + r.read = read.__get__(r) + r.status = status.__get__(r) + return r + + return opener, calls + + +def make_module(collection_import, module_args, **kwargs): + ControllerAPIModule = collection_import('plugins.module_utils.controller_api').ControllerAPIModule + cli_data = {'ANSIBLE_MODULE_ARGS': module_args} + # patch the cached args directly: AnsibleModule caches sys.argv parsing in + # basic._ANSIBLE_ARGS, so patching sys.argv would leak args between tests + with mock.patch.object(basic, '_ANSIBLE_ARGS', to_bytes(json.dumps(cli_data))): + return ControllerAPIModule(argument_spec=dict(), **kwargs) + + +@pytest.mark.parametrize( + 'token_value', + [ + 'a-token-string', + {'token': 'a-token-string', 'id': 1}, # the aap_token fact set by ansible.platform.token + ], + ids=['string', 'dict'], +) +def test_aap_token_sends_bearer_header(collection_import, token_value): + module = make_module(collection_import, {'aap_token': token_value}) + assert module.aap_token == 'a-token-string' + + opener, calls = make_recorder() + with mock.patch('ansible.module_utils.urls.Request.open', new=opener): + module.get_endpoint('ping') + + assert len(calls) == 1, calls + assert calls[0]['headers']['Authorization'] == 'Bearer a-token-string' + # a token needs no login round-trip + assert module.authenticated is False + + +@pytest.mark.parametrize('param', ['controller_oauthtoken', 'tower_oauthtoken']) +def test_aap_token_legacy_aliases(collection_import, param): + module = make_module(collection_import, {param: 'legacy-token'}) + assert module.aap_token == 'legacy-token' + + +def test_aap_token_dict_without_token_entry_fails(collection_import): + errors = [] + + def error_callback(**kwargs): + errors.append(kwargs) + raise SystemExit(1) + + with pytest.raises(SystemExit): + make_module(collection_import, {'aap_token': {'id': 1}}, error_callback=error_callback) + + assert 'did not properly contain the token entry' in errors[0]['msg'] + + +def test_no_token_falls_back_to_basic_auth(collection_import): + module = make_module(collection_import, {'controller_username': 'admin', 'controller_password': 'secret'}) + + opener, calls = make_recorder() + with mock.patch('ansible.module_utils.urls.Request.open', new=opener): + module.get_endpoint('ping') + + # first call is the authentication probe, second is the actual request + assert len(calls) == 2, calls + for call in calls: + assert call['headers']['Authorization'].startswith('Basic '), call + assert module.authenticated is True