auth_oidc: let providers declare their webkeys through public url (#83841)

This commit is contained in:
Paul Marillonnet 2023-12-20 16:08:49 +01:00
parent 7736fe52e2
commit 51820dcdbd
5 changed files with 165 additions and 0 deletions

View File

@ -603,3 +603,30 @@ class UserNotificationActivity(EventTypeWithService):
return _('user activity notified by {0}').format(actor)
else:
return _('user "{0}" activity notified by {1}').format(target_user, actor)
class ProviderKeysetChange(EventTypeDefinition):
name = 'provider.keyset.change'
label = _('identity provider keyset change')
@classmethod
def record(cls, *, provider, new_keyset, old_keyset):
new_keys = list(new_keyset - old_keyset)
new_keys.sort()
old_keys = list(old_keyset - new_keyset)
old_keys.sort()
data = {
'new': ', '.join(new_keys) or '',
'old': ', '.join(old_keys) or '',
'provider': provider,
}
super().record(data=data)
@classmethod
def get_message(cls, event, context):
new = event.get_data('new')
old = event.get_data('old')
provider = event.get_data('provider')
return _(
f'Provider {provider} renewed its keyset with new keys [{new}] whereas old keys [{old}] are now deprecated'
)

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-11-22 09:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_oidc', '0014_oidcprovider_passive_authn_supported'),
]
operations = [
migrations.AddField(
model_name='oidcprovider',
name='jwkset_url',
field=models.URLField(
blank=True,
help_text='This URL is usually part of the “well-known” URLs as per the OIDC specifications',
max_length=256,
default='',
verbose_name="URL of the provider's JSON WebKey Set",
),
),
]

View File

@ -35,6 +35,7 @@ from authentic2.apps.authenticators.models import (
AuthenticatorRelatedObjectBase,
BaseAuthenticator,
)
from authentic2.apps.journal.journal import journal
from authentic2.utils.misc import make_url
from authentic2.utils.template import validate_template
@ -101,6 +102,13 @@ class OIDCProvider(BaseAuthenticator):
blank=True,
verbose_name=pgettext_lazy('add english name between parenthesis', 'scopes'),
)
jwkset_url = models.URLField(
max_length=256,
verbose_name=_("URL of the provider's JSON WebKey Set"),
blank=True,
default='',
help_text=_('This URL is usually part of the “well-known” URLs as per the OIDC specifications'),
)
jwkset_json = JSONField(
verbose_name=_('JSON WebKey set'), null=True, blank=True, validators=[validate_jwkset]
)
@ -232,8 +240,52 @@ class OIDCProvider(BaseAuthenticator):
def save(self, *args, **kwargs):
if not self.ou:
self.ou = get_default_ou()
if self.jwkset_url:
self.set_jwkset_json_from_url()
return super().save(*args, **kwargs)
def set_jwkset_json_from_url(self):
logger = logging.getLogger(__name__)
try:
response = requests.get(
self.jwkset_url,
timeout=settings.REQUESTS_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException:
logger.error('Unable to reach JWKSet content from URL %s', self.jwkset_url)
return
if not hasattr(response, 'json'):
logger.error('JWKSet URL %s is not JSON', self.jwkset_url)
return
try:
json_value = response.json()
except json.JSONDecodeError:
logger.error('JWKSet from URL %s is invalid', self.jwkset_url)
return
if not isinstance(json_value, dict) or 'keys' not in json_value:
logger.error('JWKSet from URL %s does not contain a \'keys\' entry', self.jwkset_url)
return
if self.jwkset_json != json_value:
old_keyset = {key.get('kid') for key in (self.jwkset_json or dict()).get('keys', [])}
new_keyset = {key.get('kid') for key in json_value.get('keys', [])}
logger.debug(
'provider %s renewed its JWKSet with new keys [%s] whereas old keys [%s] are now deprecated',
self,
', '.join(new_keyset - old_keyset) or '',
', '.join(old_keyset - new_keyset) or '',
)
journal.record(
'provider.keyset.change',
provider=self.name,
new_keyset=new_keyset,
old_keyset=old_keyset,
)
# JSON is checked as part of attribute jwkset_json validation
self.jwkset_json = json_value
def authorization_claims_parameter(self):
idtoken_claims = {}
userinfo_claims = {}

View File

@ -67,7 +67,10 @@ def test_base64url_decode():
KID_RSA = '1e9gdk7'
ANOTHER_KID_RSA = 'mt80xpd'
KID_EC = 'jb20Cg8'
ANOTHER_KID_EC = 'iet7tm31'
JWKSET_URL = 'https://www.example.com/common/discovery/v3.0/keys'
header_rsa_decoded = {'alg': 'RS256', 'kid': KID_RSA}
header_ec_decoded = {'alg': 'ES256', 'kid': KID_EC}
header_hmac_decoded = {'alg': 'HS256'}
@ -381,6 +384,51 @@ def test_oidc_provider_key_sig_consistency(db):
assert provider
def test_oidc_provider_jwkset_url(db):
jwkset_url = urllib.parse.urlparse(JWKSET_URL)
@urlmatch(netloc=jwkset_url.netloc, path=jwkset_url.path)
def jwkset_url_mock(url, request):
key_rsa = JWK.generate(kty='RSA', size=512, kid=ANOTHER_KID_RSA)
key_ec = JWK.generate(kty='EC', size=256, kid=ANOTHER_KID_EC)
jwkset = JWKSet()
jwkset.add(key_rsa)
jwkset.add(key_ec)
return {
'content': json.dumps(jwkset.export(as_dict=True)),
'headers': {
'content-type': 'application/json',
},
'status_code': 200,
}
with HTTMock(jwkset_url_mock):
issuer = ('https://www.example.com',)
provider = OIDCProvider.objects.create(
ou=get_default_ou(),
name='Foo',
slug='foo',
client_id='abc',
client_secret='def',
enabled=True,
issuer=issuer,
authorization_endpoint='%s/authorize' % issuer,
token_endpoint='%s/token' % issuer,
end_session_endpoint='%s/logout' % issuer,
userinfo_endpoint='%s/user_info' % issuer,
token_revocation_endpoint='%s/revoke' % issuer,
jwkset_url=JWKSET_URL,
idtoken_algo=OIDCProvider.ALGO_RSA,
claims_parameter_supported=False,
button_label='Connect with Foo',
)
assert provider.jwkset_json
assert isinstance(provider.jwkset_json, dict)
assert provider.jwkset
assert len(provider.jwkset_json['keys']) == 2
assert {key['kid'] for key in provider.jwkset_json['keys']} == {ANOTHER_KID_RSA, ANOTHER_KID_EC}
def test_claim_mapping_wrong_source(app, oidc_provider, rf):
backend = OIDCBackend()
# set provider config according to idtoken payload

View File

@ -28,6 +28,7 @@ from authentic2.apps.journal.models import Event, EventType, _registry
from authentic2.custom_user.models import DeletedUser, Profile, ProfileType, User
from authentic2.journal import journal
from authentic2.models import Service
from authentic2_auth_oidc.models import OIDCProvider
from authentic2_auth_saml.models import SAMLAuthenticator, SetAttributeAction
from .utils import login, logout, text_content
@ -60,6 +61,7 @@ def events(db, superuser, freezer):
service = Service.objects.create(name='service')
authenticator = LoginPasswordAuthenticator.objects.create(slug='test')
saml_authenticator = SAMLAuthenticator.objects.create(slug='saml')
oidc_provider = OIDCProvider.objects.create(slug='oidc', name='OIDC')
set_attribute_action = SetAttributeAction.objects.create(authenticator=saml_authenticator)
deleted_user = User.objects.create(username='deleted', email='deleted@example.com', ou=ou, uuid='3' * 32)
@ -344,6 +346,13 @@ def events(db, superuser, freezer):
make('user.password.reset', user=deleted_user)
deleted_user.delete()
make(
'provider.keyset.change',
provider=oidc_provider.name,
new_keyset={'b', 'c', 'd', 'e'},
old_keyset={'a', 'b', 'c'},
)
# verify we created at least one event for each type
assert set(Event.objects.values_list('type__name', flat=True)) == set(_registry)
@ -789,6 +798,12 @@ def test_global_journal(app, superuser, events):
'type': 'user.password.reset',
'user': f'deleted user (#{deleted_user.old_user_id}, deleted@example.com)',
},
{
'message': 'Provider OIDC renewed its keyset with new keys [d, e] whereas old keys [a] are now deprecated',
'timestamp': 'Jan. 3, 2020, 4 p.m.',
'type': 'provider.keyset.change',
'user': '-',
},
]
agent_page = response.click('agent', index=1)