Skip to content

Commit ba959b1

Browse files
committed
Add Default JWT Algorithms from .well-known endpoint
1 parent 56bfaf4 commit ba959b1

File tree

4 files changed

+187
-3
lines changed

4 files changed

+187
-3
lines changed

ansible_base/authentication/authenticator_plugins/oidc.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ class OpenIdConnectConfiguration(BaseAuthenticatorConfiguration):
123123
)
124124

125125
JWT_ALGORITHMS = ListField(
126-
help_text=_("The algorithm(s) for decoding JWT responses from the IDP."),
126+
help_text=_(
127+
"The algorithm(s) for decoding JWT responses from the IDP. "
128+
"Leave blank to extract from the .well-known configuration (if that fails we will attempt the default algorithms). "
129+
"Set to ['none'] to not use encrypted tokens (the provider must send unencrypted tokens for this to work)"
130+
),
127131
default=None,
128132
allow_null=True,
129133
validators=[JWTAlgorithmListFieldValidator()],
@@ -300,3 +304,50 @@ def get_alternative_uid(self, **kwargs):
300304
return preferred_username
301305

302306
return None
307+
308+
def _get_jwt_algorithms(self, existing_setting=None) -> list[str]:
309+
"""
310+
Get the JWT algorithms to pass to the decode
311+
"""
312+
algorithms = []
313+
if existing_setting:
314+
# If the admin specified the algorithms then use them
315+
algorithms = existing_setting
316+
else:
317+
# Try to get the algorithms from the .well-known/openid-configuration
318+
try:
319+
logger.debug("Attempting to get the JWT algorithms from the .well-known/openid-configuration")
320+
config = self.oidc_config()
321+
322+
# First try to get signing algorithms (most common)
323+
idp_algorithms = config.get("id_token_signing_alg_values_supported")
324+
if idp_algorithms:
325+
logger.debug(f"JWT signing algorithms supported by the IDP: {idp_algorithms}")
326+
algorithms = idp_algorithms
327+
else:
328+
# Fallback to encryption algorithms if signing algorithms not found
329+
idp_algorithms = config.get("id_token_encryption_alg_values_supported")
330+
if idp_algorithms:
331+
logger.debug(f"JWT encryption algorithms supported by the IDP: {idp_algorithms}")
332+
algorithms = idp_algorithms
333+
else:
334+
# Try userinfo signing algorithms as another fallback
335+
idp_algorithms = config.get("userinfo_signing_alg_values_supported")
336+
if idp_algorithms:
337+
logger.debug(f"JWT userinfo signing algorithms supported by the IDP: {idp_algorithms}")
338+
algorithms = idp_algorithms
339+
else:
340+
raise Exception("No algorithms found in OIDC config")
341+
except Exception as e:
342+
# Fallback to default algorithms if we can't get them from the .well-known endpoint
343+
lib_defaults = OpenIdConnectAuth.JWT_ALGORITHMS
344+
logger.error(f"Unable to get JWT algorithms from the .well-known/openid-configuration, defaulting to {lib_defaults}: {e}")
345+
algorithms = lib_defaults
346+
347+
# Ensure we always return a list
348+
if not isinstance(algorithms, list):
349+
algorithms = [algorithms] if algorithms else []
350+
351+
# There is a property on self that we can also set that is used in some places
352+
self.JWT_ALGORITHMS = algorithms
353+
return algorithms

ansible_base/authentication/models/authenticator.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ class Authenticator(UniqueNamedCommonModel):
5252
on_delete=SET_NULL,
5353
)
5454

55+
def save_default_jwt_algorithms(self):
56+
try:
57+
# Create a proper plugin instance with the database instance
58+
from ansible_base.authentication.authenticator_plugins.utils import get_authenticator_class
59+
60+
authenticator_class = get_authenticator_class(self.type)
61+
plugin_instance = authenticator_class(database_instance=self)
62+
63+
# Try to get algorithms from .well-known endpoint
64+
import logging
65+
66+
logger = logging.getLogger('ansible_base.authentication.models.authenticator')
67+
logger.info("Auto-populating JWT algorithms for OIDC authenticator from .well-known endpoint")
68+
69+
algorithms = plugin_instance._get_jwt_algorithms()
70+
if algorithms:
71+
self.configuration['JWT_ALGORITHMS'] = algorithms
72+
logger.info(f"Successfully populated JWT algorithms: {algorithms}")
73+
except Exception as e:
74+
import logging
75+
76+
logger = logging.getLogger('ansible_base.authentication.models.authenticator')
77+
logger.warning(f"Could not auto-populate JWT algorithms for OIDC authenticator: {e}")
78+
# Don't fail the save operation, just log the warning
79+
5580
def save(self, *args, **kwargs):
5681
from ansible_base.lib.utils.encryption import ansible_encryption
5782

@@ -65,6 +90,10 @@ def save(self, *args, **kwargs):
6590
if field in self.configuration:
6691
self.configuration[field] = ansible_encryption.encrypt_string(self.configuration[field])
6792

93+
# Auto-populate JWT algorithms for OIDC authenticators if not set
94+
if self.type == "ansible_base.authentication.authenticator_plugins.oidc" and self.configuration and not self.configuration.get('JWT_ALGORITHMS'):
95+
self.save_default_jwt_algorithms()
96+
6897
if not self.slug:
6998
self.slug = generate_authenticator_slug()
7099
super().save(*args, **kwargs)

test_app/tests/authentication/authenticator_plugins/test_oidc.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,13 @@ def encrypted(self, isEncrypted):
147147
def json(self):
148148
return json.dumps({"key": "value"})
149149

150-
mocksetting.return_value = "VALUE"
150+
# Mock the setting to return appropriate values
151+
def mock_setting(key, default=None):
152+
if key == "JWT_ALGORITHMS":
153+
return ["RS256"] # Return a valid algorithm list
154+
return "VALUE"
155+
156+
mocksetting.side_effect = mock_setting
151157

152158
ap = AuthenticatorPlugin()
153159

@@ -167,7 +173,8 @@ def json(self):
167173
mockeddecode.return_value = mr.json()
168174
data = ap.user_data("token")
169175

170-
mockeddecode.assert_called_once_with('token', key='-----BEGIN PUBLIC KEY-----\nVALUE\n-----END PUBLIC KEY-----', algorithms='VALUE', audience='VALUE')
176+
# The algorithms should be the mocked value
177+
mockeddecode.assert_called_once_with('token', key='-----BEGIN PUBLIC KEY-----\nVALUE\n-----END PUBLIC KEY-----', algorithms=['RS256'], audience='VALUE')
171178
assert "key" in data
172179

173180
# Decode failure

test_app/tests/authentication/models/test_authenticator.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,100 @@ def test_dupe_slug(ldap_authenticator):
4747

4848
dupe.save()
4949
assert dupe.slug != ldap_slug, "authenticator slugs should be unique"
50+
51+
52+
@pytest.mark.django_db
53+
@mock.patch("ansible_base.authentication.authenticator_plugins.oidc.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256", "HS256"])
54+
@mock.patch("logging.getLogger")
55+
def test_oidc_jwt_algorithms_auto_population(mock_get_logger):
56+
"""Test that JWT algorithms are automatically populated when creating an OIDC authenticator"""
57+
from ansible_base.authentication.models import Authenticator
58+
59+
# Mock the logger
60+
mock_logger = mock.MagicMock()
61+
mock_get_logger.return_value = mock_logger
62+
63+
# Create OIDC authenticator without JWT_ALGORITHMS
64+
oidc_config = {
65+
"OIDC_ENDPOINT": "https://example.com",
66+
"VERIFY_SSL": True,
67+
"KEY": "test-client-id",
68+
"SECRET": "test-client-secret",
69+
}
70+
71+
# Mock the OIDC plugin to return algorithms from .well-known
72+
with mock.patch("ansible_base.authentication.authenticator_plugins.oidc.AuthenticatorPlugin._get_jwt_algorithms") as mock_get_algs:
73+
mock_get_algs.return_value = ["RS256", "ES256"]
74+
75+
oidc_auth = Authenticator.objects.create(name="Test OIDC", type="ansible_base.authentication.authenticator_plugins.oidc", configuration=oidc_config)
76+
77+
# Verify that JWT_ALGORITHMS was populated
78+
assert "JWT_ALGORITHMS" in oidc_auth.configuration
79+
assert oidc_auth.configuration["JWT_ALGORITHMS"] == ["RS256", "ES256"]
80+
mock_logger.info.assert_called_with("Successfully populated JWT algorithms: ['RS256', 'ES256']")
81+
82+
83+
@pytest.mark.django_db
84+
def test_oidc_jwt_algorithms_not_populated_when_already_set():
85+
"""Test that JWT algorithms are not modified when already configured"""
86+
from ansible_base.authentication.models import Authenticator
87+
88+
# Create OIDC authenticator with JWT_ALGORITHMS already set
89+
oidc_config = {
90+
"OIDC_ENDPOINT": "https://example.com",
91+
"VERIFY_SSL": True,
92+
"KEY": "test-client-id",
93+
"SECRET": "test-client-secret",
94+
"JWT_ALGORITHMS": ["RS256"], # Already configured
95+
}
96+
97+
oidc_auth = Authenticator.objects.create(
98+
name="Test OIDC Configured", type="ansible_base.authentication.authenticator_plugins.oidc", configuration=oidc_config
99+
)
100+
101+
# Verify that JWT_ALGORITHMS was not modified
102+
assert oidc_auth.configuration["JWT_ALGORITHMS"] == ["RS256"]
103+
104+
105+
@pytest.mark.django_db
106+
def test_non_oidc_authenticator_not_affected():
107+
"""Test that non-OIDC authenticators are not affected by JWT algorithm logic"""
108+
from ansible_base.authentication.models import Authenticator
109+
110+
# Create LDAP authenticator (non-OIDC)
111+
ldap_config = {
112+
"SERVER_URI": "ldap://example.com",
113+
"BIND_DN": "cn=admin,dc=example,dc=com",
114+
"BIND_PASSWORD": "password",
115+
"USER_SEARCH": ["ou=users,dc=example,dc=com", "SCOPE_SUBTREE", "(sAMAccountName=%(user)s)"],
116+
}
117+
118+
ldap_auth = Authenticator.objects.create(name="Test LDAP", type="ansible_base.authentication.authenticator_plugins.ldap", configuration=ldap_config)
119+
120+
# Verify that LDAP authenticator doesn't have JWT_ALGORITHMS
121+
assert "JWT_ALGORITHMS" not in ldap_auth.configuration
122+
123+
124+
@pytest.mark.django_db
125+
@mock.patch("ansible_base.authentication.authenticator_plugins.oidc.OpenIdConnectAuth.JWT_ALGORITHMS", ["RS256", "HS256"])
126+
@mock.patch("logging.getLogger")
127+
def test_oidc_jwt_algorithms_auto_population_on_update(mock_get_logger):
128+
"""Test that JWT algorithms are auto-populated when updating an OIDC authenticator"""
129+
from ansible_base.authentication.models import Authenticator
130+
131+
# Mock the logger
132+
mock_logger = mock.MagicMock()
133+
mock_get_logger.return_value = mock_logger
134+
135+
# Create OIDC authenticator without JWT_ALGORITHMS
136+
oidc_config = {
137+
"OIDC_ENDPOINT": "https://example.com",
138+
"VERIFY_SSL": True,
139+
"KEY": "test-client-id",
140+
"SECRET": "test-client-secret",
141+
}
142+
143+
oidc_auth = Authenticator.objects.create(name="Test OIDC Update", type="ansible_base.authentication.authenticator_plugins.oidc", configuration=oidc_config)
144+
145+
assert "JWT_ALGORITHMS" in oidc_auth.configuration
146+
assert oidc_auth.configuration["JWT_ALGORITHMS"] == ["RS256", "HS256"]

0 commit comments

Comments
 (0)