From 75adf22646fad1d6f65a9485b49262e25580c764 Mon Sep 17 00:00:00 2001 From: Zack Kayyali Date: Thu, 21 Aug 2025 19:00:21 -0400 Subject: [PATCH] Add Default JWT Algorithms from .well-known endpoint --- .../authenticator_plugins/oidc.py | 59 ++- .../authentication/models/authenticator.py | 29 ++ .../authenticator_plugins/test_oidc.py | 491 +++++++++++++++++- .../models/test_authenticator.py | 97 ++++ 4 files changed, 672 insertions(+), 4 deletions(-) diff --git a/ansible_base/authentication/authenticator_plugins/oidc.py b/ansible_base/authentication/authenticator_plugins/oidc.py index 890720141..ab54e6721 100644 --- a/ansible_base/authentication/authenticator_plugins/oidc.py +++ b/ansible_base/authentication/authenticator_plugins/oidc.py @@ -123,7 +123,11 @@ class OpenIdConnectConfiguration(BaseAuthenticatorConfiguration): ) JWT_ALGORITHMS = ListField( - help_text=_("The algorithm(s) for decoding JWT responses from the IDP."), + help_text=_( + "The algorithm(s) for decoding JWT responses from the IDP. " + "Leave blank to extract from the .well-known configuration (if that fails we will attempt the default algorithms). " + "Set to ['none'] to not use encrypted tokens (the provider must send unencrypted tokens for this to work)" + ), default=None, allow_null=True, validators=[JWTAlgorithmListFieldValidator()], @@ -300,3 +304,56 @@ def get_alternative_uid(self, **kwargs): return preferred_username return None + + def _discover_algorithms_from_config(self, config): + """ + Discover JWT algorithms from OIDC configuration + """ + # Try signing algorithms first (most common) + algorithms = config.get("id_token_signing_alg_values_supported") + if algorithms: + logger.debug(f"JWT signing algorithms supported by the IDP: {algorithms}") + return algorithms + + # Fallback to encryption algorithms + algorithms = config.get("id_token_encryption_alg_values_supported") + if algorithms: + logger.debug(f"JWT encryption algorithms supported by the IDP: {algorithms}") + return algorithms + + # Try userinfo signing algorithms as final fallback + algorithms = config.get("userinfo_signing_alg_values_supported") + if algorithms: + logger.debug(f"JWT userinfo signing algorithms supported by the IDP: {algorithms}") + return algorithms + + return None + + def _get_jwt_algorithms(self, existing_setting=None) -> list[str]: + """ + Get the JWT algorithms to pass to the decode + """ + if existing_setting: + algorithms = existing_setting + else: + try: + logger.debug("Attempting to get the JWT algorithms from the .well-known/openid-configuration") + config = self.oidc_config() + algorithms = self._discover_algorithms_from_config(config) + + if not algorithms: + raise Exception("No algorithms found in OIDC config") + + except Exception as e: + # Fallback to default algorithms if discovery fails + lib_defaults = OpenIdConnectAuth.JWT_ALGORITHMS + logger.error(f"Unable to get JWT algorithms from the .well-known/openid-configuration, defaulting to {lib_defaults}: {e}") + algorithms = lib_defaults + + # Ensure we always return a list + if not isinstance(algorithms, list): + algorithms = [algorithms] if algorithms else [] + + # Set the property for use elsewhere + self.JWT_ALGORITHMS = algorithms + return algorithms diff --git a/ansible_base/authentication/models/authenticator.py b/ansible_base/authentication/models/authenticator.py index fbec14ebe..38959d9d0 100644 --- a/ansible_base/authentication/models/authenticator.py +++ b/ansible_base/authentication/models/authenticator.py @@ -52,6 +52,31 @@ class Authenticator(UniqueNamedCommonModel): on_delete=SET_NULL, ) + def save_default_jwt_algorithms(self): + try: + # Create a proper plugin instance with the database instance + from ansible_base.authentication.authenticator_plugins.utils import get_authenticator_class + + authenticator_class = get_authenticator_class(self.type) + plugin_instance = authenticator_class(database_instance=self) + + # Try to get algorithms from .well-known endpoint + import logging + + logger = logging.getLogger('ansible_base.authentication.models.authenticator') + logger.info("Auto-populating JWT algorithms for OIDC authenticator from .well-known endpoint") + + algorithms = plugin_instance._get_jwt_algorithms() + if algorithms: + self.configuration['JWT_ALGORITHMS'] = algorithms + logger.info(f"Successfully populated JWT algorithms: {algorithms}") + except Exception as e: + import logging + + logger = logging.getLogger('ansible_base.authentication.models.authenticator') + logger.warning(f"Could not auto-populate JWT algorithms for OIDC authenticator: {e}") + # Don't fail the save operation, just log the warning + def save(self, *args, **kwargs): from ansible_base.lib.utils.encryption import ansible_encryption @@ -65,6 +90,10 @@ def save(self, *args, **kwargs): if field in self.configuration: self.configuration[field] = ansible_encryption.encrypt_string(self.configuration[field]) + # Auto-populate JWT algorithms for OIDC authenticators if not set + if self.type == "ansible_base.authentication.authenticator_plugins.oidc" and self.configuration and not self.configuration.get('JWT_ALGORITHMS'): + self.save_default_jwt_algorithms() + if not self.slug: self.slug = generate_authenticator_slug() super().save(*args, **kwargs) diff --git a/test_app/tests/authentication/authenticator_plugins/test_oidc.py b/test_app/tests/authentication/authenticator_plugins/test_oidc.py index dbea5c080..b55e2b0ad 100644 --- a/test_app/tests/authentication/authenticator_plugins/test_oidc.py +++ b/test_app/tests/authentication/authenticator_plugins/test_oidc.py @@ -2,9 +2,11 @@ from unittest import mock import pytest +from django.core.exceptions import ValidationError from jwt.exceptions import PyJWTError +from social_core.backends.open_id_connect import OpenIdConnectAuth -from ansible_base.authentication.authenticator_plugins.oidc import AuthenticatorPlugin +from ansible_base.authentication.authenticator_plugins.oidc import AuthenticatorPlugin, OpenIdConnectConfiguration from ansible_base.authentication.session import SessionAuthentication from ansible_base.lib.utils.response import get_fully_qualified_url, get_relative_url @@ -147,7 +149,13 @@ def encrypted(self, isEncrypted): def json(self): return json.dumps({"key": "value"}) - mocksetting.return_value = "VALUE" + # Mock the setting to return appropriate values + def mock_setting(key, default=None): + if key == "JWT_ALGORITHMS": + return ["RS256"] # Return a valid algorithm list + return "VALUE" + + mocksetting.side_effect = mock_setting ap = AuthenticatorPlugin() @@ -167,9 +175,486 @@ def json(self): mockeddecode.return_value = mr.json() data = ap.user_data("token") - mockeddecode.assert_called_once_with('token', key='-----BEGIN PUBLIC KEY-----\nVALUE\n-----END PUBLIC KEY-----', algorithms='VALUE', audience='VALUE') + # The algorithms should be the mocked value + mockeddecode.assert_called_once_with('token', key='-----BEGIN PUBLIC KEY-----\nVALUE\n-----END PUBLIC KEY-----', algorithms=['RS256'], audience='VALUE') assert "key" in data # Decode failure mockeddecode.side_effect = PyJWTError() assert ap.user_data("token") is None + + +def test_jwt_algorithm_list_field_validator(): + """Test JWT algorithm list field validator""" + from ansible_base.authentication.authenticator_plugins.oidc import JWTAlgorithmListFieldValidator + + validator = JWTAlgorithmListFieldValidator() + + # Test valid algorithms + valid_algorithms = ["RS256", "HS256"] + try: + validator(valid_algorithms) + except ValidationError: + pytest.fail("ValidationError raised unexpectedly for valid algorithms") + + # Test invalid algorithms + invalid_algorithms = ["RS256", "INVALID_ALG"] + with pytest.raises(ValidationError) as exc_info: + validator(invalid_algorithms) + + assert "INVALID_ALG" in str(exc_info.value) + assert "RS256" in str(exc_info.value) + + +def test_openid_connect_configuration(): + """Test OpenIdConnectConfiguration class""" + from ansible_base.authentication.authenticator_plugins.oidc import OpenIdConnectConfiguration + + config = OpenIdConnectConfiguration() + + # Test that required fields are present + assert 'OIDC_ENDPOINT' in config.fields + assert 'VERIFY_SSL' in config.fields + assert 'KEY' in config.fields + assert 'SECRET' in config.fields + + # Test that optional fields have correct defaults + assert config.fields['ID_KEY'].default == "sub" + assert config.fields['ID_TOKEN_MAX_AGE'].default == 600 + assert config.fields['RESPONSE_TYPE'].default == "code" + assert config.fields['SCOPE'].default == ["openid", "profile", "email"] + assert not config.fields['REDIRECT_STATE'].default + + +def test_authenticator_plugin_properties(): + """Test AuthenticatorPlugin properties and basic methods""" + ap = AuthenticatorPlugin() + + # Test type and category + assert ap.type == "open_id_connect" + assert ap.category == "sso" + assert ap.configuration_encrypted_fields == ['SECRET'] + + # Test configuration class + assert ap.configuration_class == OpenIdConnectConfiguration + + +def test_groups_claim_property(): + """Test groups_claim property""" + ap = AuthenticatorPlugin() + + # Mock the setting method + with mock.patch.object(ap, 'setting') as mock_setting: + mock_setting.return_value = "custom_groups" + assert ap.groups_claim == "custom_groups" + mock_setting.assert_called_once_with('GROUPS_CLAIM') + + +def test_get_user_groups(): + """Test get_user_groups method""" + ap = AuthenticatorPlugin() + + extra_groups = ["group1", "group2"] + result = ap.get_user_groups(extra_groups) + + assert result == extra_groups + + +def test_oidc_config(): + """Test oidc_config method""" + ap = AuthenticatorPlugin() + + # Mock the get_json method + with mock.patch.object(ap, 'get_json') as mock_get_json: + mock_get_json.return_value = {"test": "config"} + + # Mock the oidc_endpoint method + with mock.patch.object(ap, 'oidc_endpoint') as mock_endpoint: + mock_endpoint.return_value = "https://example.com" + + result = ap.oidc_config() + + assert result == {"test": "config"} + mock_get_json.assert_called_once_with("https://example.com/.well-known/openid-configuration") + + +def test_public_key_with_key(): + """Test public_key method when key is provided""" + ap = AuthenticatorPlugin() + + # Mock the setting method + with mock.patch.object(ap, 'setting') as mock_setting: + mock_setting.return_value = "test_key_content" + + result = ap.public_key() + + expected = "-----BEGIN PUBLIC KEY-----\ntest_key_content\n-----END PUBLIC KEY-----" + assert result == expected + + +def test_public_key_with_formatted_key(): + """Test public_key method when key is already formatted""" + ap = AuthenticatorPlugin() + + # Mock the setting method + with mock.patch.object(ap, 'setting') as mock_setting: + formatted_key = "-----BEGIN PUBLIC KEY-----\ntest_key_content\n-----END PUBLIC KEY-----" + mock_setting.return_value = formatted_key + + result = ap.public_key() + + assert result == formatted_key + + +def test_public_key_no_key(): + """Test public_key method when no key is provided""" + ap = AuthenticatorPlugin() + + # Mock the setting method + with mock.patch.object(ap, 'setting') as mock_setting: + mock_setting.return_value = None + + result = ap.public_key() + + assert result is None + + +def test_user_data_json_response(): + """Test user_data method with JSON response""" + ap = AuthenticatorPlugin() + + # Mock database_instance to avoid slug attribute error + mock_db_instance = mock.MagicMock() + mock_db_instance.slug = "test_slug" + ap.database_instance = mock_db_instance + + # Mock the request method + class MockResponse: + def __init__(self): + self.headers = {"Content-Type": "application/json"} + + def json(self): + return {"user": "data"} + + with mock.patch.object(ap, 'request') as mock_request: + mock_request.return_value = MockResponse() + + # Mock userinfo_url to avoid calling the actual method + with mock.patch.object(ap, 'userinfo_url') as mock_userinfo_url: + mock_userinfo_url.return_value = "https://example.com/userinfo" + + result = ap.user_data("token") + + assert result == {"user": "data"} + + +def test_user_data_jwt_response_no_public_key(): + """Test user_data method with JWT response but no public key""" + ap = AuthenticatorPlugin() + + # Mock database_instance to avoid slug attribute error + mock_db_instance = mock.MagicMock() + mock_db_instance.slug = "test_slug" + ap.database_instance = mock_db_instance + + # Mock the request method + class MockResponse: + def __init__(self): + self.headers = {"Content-Type": "application/jwt"} + + with mock.patch.object(ap, 'request') as mock_request: + mock_request.return_value = MockResponse() + + # Mock userinfo_url to avoid calling the actual method + with mock.patch.object(ap, 'userinfo_url') as mock_userinfo_url: + mock_userinfo_url.return_value = "https://example.com/userinfo" + + # Mock public_key to return None + with mock.patch.object(ap, 'public_key') as mock_pubkey: + mock_pubkey.return_value = None + + result = ap.user_data("token") + + assert result is None + + +def test_user_data_jwt_response_with_public_key(): + """Test user_data method with JWT response and public key""" + ap = AuthenticatorPlugin() + + # Mock the request method + class MockResponse: + def __init__(self): + self.headers = {"Content-Type": "application/jwt"} + + with mock.patch.object(ap, 'request') as mock_request: + mock_request.return_value = MockResponse() + + # Mock public_key to return a key + with mock.patch.object(ap, 'public_key') as mock_pubkey: + mock_pubkey.return_value = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" + + # Mock the setting method + with mock.patch.object(ap, 'setting') as mock_setting: + mock_setting.side_effect = lambda key, default=None: ["RS256"] if key == "JWT_ALGORITHMS" else "test_audience" + + # Mock jwt.decode + with mock.patch('jwt.decode') as mock_jwt_decode: + mock_jwt_decode.return_value = {"decoded": "data"} + + result = ap.user_data("token") + + assert result == {"decoded": "data"} + + +def test_user_data_jwt_response_decode_error(): + """Test user_data method with JWT response but decode error""" + ap = AuthenticatorPlugin() + + # Mock the request method + class MockResponse: + def __init__(self): + self.headers = {"Content-Type": "application/jwt"} + + with mock.patch.object(ap, 'request') as mock_request: + mock_request.return_value = MockResponse() + + # Mock public_key to return a key + with mock.patch.object(ap, 'public_key') as mock_pubkey: + mock_pubkey.return_value = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" + + # Mock the setting method + with mock.patch.object(ap, 'setting') as mock_setting: + mock_setting.side_effect = lambda key, default=None: ["RS256"] if key == "JWT_ALGORITHMS" else "test_audience" + + # Mock jwt.decode to raise an error + with mock.patch('jwt.decode') as mock_jwt_decode: + mock_jwt_decode.side_effect = PyJWTError("Invalid token") + + result = ap.user_data("token") + + assert result is None + + +def test_get_alternative_uid_different_values(): + """Test get_alternative_uid method when values are different""" + ap = AuthenticatorPlugin() + + kwargs = {"response": {"preferred_username": "user123"}, "uid": "different_uid"} + + result = ap.get_alternative_uid(**kwargs) + + assert result == "user123" + + +def test_get_alternative_uid_same_values(): + """Test get_alternative_uid method when values are the same""" + ap = AuthenticatorPlugin() + + kwargs = {"response": {"preferred_username": "user123"}, "uid": "user123"} + + result = ap.get_alternative_uid(**kwargs) + + assert result is None + + +def test_get_alternative_uid_missing_preferred_username(): + """Test get_alternative_uid method when preferred_username is missing""" + ap = AuthenticatorPlugin() + + kwargs = {"response": {}, "uid": "user123"} + + result = ap.get_alternative_uid(**kwargs) + + assert result is None + + +def test_discover_algorithms_from_config_signing(): + """Test _discover_algorithms_from_config method with signing algorithms""" + ap = AuthenticatorPlugin() + + config = {"id_token_signing_alg_values_supported": ["RS256", "ES256"]} + + result = ap._discover_algorithms_from_config(config) + + assert result == ["RS256", "ES256"] + + +def test_discover_algorithms_from_config_encryption(): + """Test _discover_algorithms_from_config method with encryption algorithms""" + ap = AuthenticatorPlugin() + + config = {"id_token_encryption_alg_values_supported": ["A256GCM", "A128GCM"]} + + result = ap._discover_algorithms_from_config(config) + + assert result == ["A256GCM", "A128GCM"] + + +def test_discover_algorithms_from_config_userinfo(): + """Test _discover_algorithms_from_config method with userinfo algorithms""" + ap = AuthenticatorPlugin() + + config = {"userinfo_signing_alg_values_supported": ["HS256", "HS512"]} + + result = ap._discover_algorithms_from_config(config) + + assert result == ["HS256", "HS512"] + + +def test_discover_algorithms_from_config_no_algorithms(): + """Test _discover_algorithms_from_config method with no algorithms""" + ap = AuthenticatorPlugin() + + config = {} + + result = ap._discover_algorithms_from_config(config) + + assert result is None + + +def test_get_jwt_algorithms_with_existing_setting(): + """Test _get_jwt_algorithms method with existing setting""" + ap = AuthenticatorPlugin() + + existing_setting = ["RS256", "HS256"] + + result = ap._get_jwt_algorithms(existing_setting) + + assert result == ["RS256", "HS256"] + assert ap.JWT_ALGORITHMS == ["RS256", "HS256"] + + +def test_get_jwt_algorithms_with_string_setting(): + """Test _get_jwt_algorithms method with string setting (should be converted to list)""" + ap = AuthenticatorPlugin() + + existing_setting = "RS256" + + result = ap._get_jwt_algorithms(existing_setting) + + assert result == ["RS256"] + assert ap.JWT_ALGORITHMS == ["RS256"] + + +def test_get_jwt_algorithms_with_none_setting(): + """Test _get_jwt_algorithms method with None setting (falls back to discovery then defaults)""" + ap = AuthenticatorPlugin() + + # Mock oidc_config to fail, so it falls back to defaults + with mock.patch.object(ap, 'oidc_config') as mock_oidc_config: + mock_oidc_config.side_effect = Exception("Discovery failed") + + # Mock OpenIdConnectAuth.JWT_ALGORITHMS + with mock.patch("social_core.backends.open_id_connect.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256"]): + existing_setting = None + + result = ap._get_jwt_algorithms(existing_setting) + + assert result == ["RS256"] + assert ap.JWT_ALGORITHMS == ["RS256"] + + +def test_get_jwt_algorithms_discovery_success(): + """Test _get_jwt_algorithms method with successful discovery""" + ap = AuthenticatorPlugin() + + # Mock oidc_config to return a config with algorithms + with mock.patch.object(ap, 'oidc_config') as mock_oidc_config: + mock_oidc_config.return_value = {"id_token_signing_alg_values_supported": ["RS256", "ES256"]} + + result = ap._get_jwt_algorithms() + + assert result == ["RS256", "ES256"] + assert ap.JWT_ALGORITHMS == ["RS256", "ES256"] + + +def test_get_jwt_algorithms_discovery_failure(): + """Test _get_jwt_algorithms method with discovery failure""" + ap = AuthenticatorPlugin() + + # Mock oidc_config to return a config with no algorithms + with mock.patch.object(ap, 'oidc_config') as mock_oidc_config: + mock_oidc_config.return_value = {} + + # Mock OpenIdConnectAuth.JWT_ALGORITHMS + with mock.patch("social_core.backends.open_id_connect.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256"]): + result = ap._get_jwt_algorithms() + + assert result == ["RS256"] + assert ap.JWT_ALGORITHMS == ["RS256"] + + +def test_get_jwt_algorithms_discovery_exception(): + """Test _get_jwt_algorithms method with discovery exception""" + ap = AuthenticatorPlugin() + + # Mock oidc_config to raise an exception + with mock.patch.object(ap, 'oidc_config') as mock_oidc_config: + mock_oidc_config.side_effect = Exception("Network error") + + # Mock OpenIdConnectAuth.JWT_ALGORITHMS + with mock.patch("social_core.backends.open_id_connect.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256"]): + result = ap._get_jwt_algorithms() + + assert result == ["RS256"] + assert ap.JWT_ALGORITHMS == ["RS256"] + + +def test_extra_data_with_permissions(): + """Test extra_data method with permissions in response""" + ap = AuthenticatorPlugin() + + # Mock get_setting + with mock.patch("ansible_base.authentication.authenticator_plugins.oidc.get_setting") as mock_get_setting: + mock_get_setting.return_value = "is_system_auditor" + + # Mock super().extra_data + with mock.patch.object(OpenIdConnectAuth, 'extra_data') as mock_super_extra_data: + mock_super_extra_data.return_value = {"extra": "data"} + + user = mock.MagicMock() + backend = mock.MagicMock() + response = {"is_superuser": True, "is_system_auditor": False} + + # Create a proper mock social object with extra_data as a dict + social_mock = mock.MagicMock() + social_mock.extra_data = {} + + kwargs = {"social": social_mock} + + result = ap.extra_data(user, backend, response, **kwargs) + + assert result == {"extra": "data"} + assert kwargs["social"].extra_data["is_superuser"] is True + assert kwargs["social"].extra_data["is_system_auditor"] is False + + +def test_extra_data_without_permissions(): + """Test extra_data method without permissions in response""" + ap = AuthenticatorPlugin() + + # Mock get_setting + with mock.patch("ansible_base.authentication.authenticator_plugins.oidc.get_setting") as mock_get_setting: + mock_get_setting.return_value = "is_system_auditor" + + # Mock super().extra_data + with mock.patch.object(OpenIdConnectAuth, 'extra_data') as mock_super_extra_data: + mock_super_extra_data.return_value = {"extra": "data"} + + user = mock.MagicMock() + backend = mock.MagicMock() + response = {"email": "user@example.com"} + + # Create a proper mock social object with extra_data as a dict + social_mock = mock.MagicMock() + social_mock.extra_data = {} + + kwargs = {"social": social_mock} + + result = ap.extra_data(user, backend, response, **kwargs) + + assert result == {"extra": "data"} + # No permissions should be added to extra_data + assert "is_superuser" not in kwargs["social"].extra_data + assert "is_system_auditor" not in kwargs["social"].extra_data diff --git a/test_app/tests/authentication/models/test_authenticator.py b/test_app/tests/authentication/models/test_authenticator.py index 79ea5a290..9d4b2d564 100644 --- a/test_app/tests/authentication/models/test_authenticator.py +++ b/test_app/tests/authentication/models/test_authenticator.py @@ -47,3 +47,100 @@ def test_dupe_slug(ldap_authenticator): dupe.save() assert dupe.slug != ldap_slug, "authenticator slugs should be unique" + + +@pytest.mark.django_db +@mock.patch("ansible_base.authentication.authenticator_plugins.oidc.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256", "HS256"]) +@mock.patch("logging.getLogger") +def test_oidc_jwt_algorithms_auto_population(mock_get_logger): + """Test that JWT algorithms are automatically populated when creating an OIDC authenticator""" + from ansible_base.authentication.models import Authenticator + + # Mock the logger + mock_logger = mock.MagicMock() + mock_get_logger.return_value = mock_logger + + # Create OIDC authenticator without JWT_ALGORITHMS + oidc_config = { + "OIDC_ENDPOINT": "https://example.com", + "VERIFY_SSL": True, + "KEY": "test-client-id", + "SECRET": "test-client-secret", + } + + # Mock the OIDC plugin to return algorithms from .well-known + with mock.patch("ansible_base.authentication.authenticator_plugins.oidc.AuthenticatorPlugin._get_jwt_algorithms") as mock_get_algs: + mock_get_algs.return_value = ["RS256", "ES256"] + + oidc_auth = Authenticator.objects.create(name="Test OIDC", type="ansible_base.authentication.authenticator_plugins.oidc", configuration=oidc_config) + + # Verify that JWT_ALGORITHMS was populated + assert "JWT_ALGORITHMS" in oidc_auth.configuration + assert oidc_auth.configuration["JWT_ALGORITHMS"] == ["RS256", "ES256"] + mock_logger.info.assert_called_with("Successfully populated JWT algorithms: ['RS256', 'ES256']") + + +@pytest.mark.django_db +def test_oidc_jwt_algorithms_not_populated_when_already_set(): + """Test that JWT algorithms are not modified when already configured""" + from ansible_base.authentication.models import Authenticator + + # Create OIDC authenticator with JWT_ALGORITHMS already set + oidc_config = { + "OIDC_ENDPOINT": "https://example.com", + "VERIFY_SSL": True, + "KEY": "test-client-id", + "SECRET": "test-client-secret", + "JWT_ALGORITHMS": ["RS256"], # Already configured + } + + oidc_auth = Authenticator.objects.create( + name="Test OIDC Configured", type="ansible_base.authentication.authenticator_plugins.oidc", configuration=oidc_config + ) + + # Verify that JWT_ALGORITHMS was not modified + assert oidc_auth.configuration["JWT_ALGORITHMS"] == ["RS256"] + + +@pytest.mark.django_db +def test_non_oidc_authenticator_not_affected(): + """Test that non-OIDC authenticators are not affected by JWT algorithm logic""" + from ansible_base.authentication.models import Authenticator + + # Create LDAP authenticator (non-OIDC) + ldap_config = { + "SERVER_URI": "ldap://example.com", + "BIND_DN": "cn=admin,dc=example,dc=com", + "BIND_PASSWORD": "password", + "USER_SEARCH": ["ou=users,dc=example,dc=com", "SCOPE_SUBTREE", "(sAMAccountName=%(user)s)"], + } + + ldap_auth = Authenticator.objects.create(name="Test LDAP", type="ansible_base.authentication.authenticator_plugins.ldap", configuration=ldap_config) + + # Verify that LDAP authenticator doesn't have JWT_ALGORITHMS + assert "JWT_ALGORITHMS" not in ldap_auth.configuration + + +@pytest.mark.django_db +@mock.patch("ansible_base.authentication.authenticator_plugins.oidc.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256", "HS256"]) +@mock.patch("logging.getLogger") +def test_oidc_jwt_algorithms_auto_population_on_update(mock_get_logger): + """Test that JWT algorithms are auto-populated when updating an OIDC authenticator""" + from ansible_base.authentication.models import Authenticator + + # Mock the logger + mock_logger = mock.MagicMock() + mock_get_logger.return_value = mock_logger + + # Create OIDC authenticator without JWT_ALGORITHMS + oidc_config = { + "OIDC_ENDPOINT": "https://example.com", + "VERIFY_SSL": True, + "KEY": "test-client-id", + "SECRET": "test-client-secret", + } + + oidc_auth = Authenticator.objects.create(name="Test OIDC Update", type="ansible_base.authentication.authenticator_plugins.oidc", configuration=oidc_config) + + assert "JWT_ALGORITHMS" in oidc_auth.configuration + assert oidc_auth.configuration["JWT_ALGORITHMS"] == ["RS256", "HS256"]