auth_oidc: let providers declare their webkeys through public url (#83841)
This commit is contained in:
parent
7736fe52e2
commit
51820dcdbd
|
@ -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'
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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 = {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue