From 2b46edf4c578c124a29a18cc95ec9ca7a6f1b869 Mon Sep 17 00:00:00 2001 From: Paul Marillonnet Date: Fri, 13 Mar 2020 16:21:03 +0100 Subject: [PATCH] auth_oidc: render templated claim values during authn (#37871) --- src/authentic2_auth_oidc/backends.py | 29 +++++++---- tests/test_auth_oidc.py | 76 +++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/authentic2_auth_oidc/backends.py b/src/authentic2_auth_oidc/backends.py index 9bc9012f5..eb665eb52 100644 --- a/src/authentic2_auth_oidc/backends.py +++ b/src/authentic2_auth_oidc/backends.py @@ -1,5 +1,5 @@ # authentic2 - versatile identity manager -# Copyright (C) 2010-2019 Entr'ouvert +# Copyright (C) 2010-2020 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published @@ -31,6 +31,7 @@ from django_rbac.utils import get_ou_model from authentic2.crypto import base64url_encode from authentic2 import app_settings, hooks +from authentic2.utils.template import Template from . import models, utils @@ -167,7 +168,9 @@ class OIDCBackend(ModelBackend): for claim_mapping in provider.claim_mappings.all(): claim = claim_mapping.claim if claim_mapping.required: - if claim_mapping.idtoken_claim and claim not in id_token: + if '{{' in claim or '{%' in claim: + logger.warning(u'claim \'%r\' is templated, it cannot be set as required') + elif claim_mapping.idtoken_claim and claim not in id_token: logger.warning(u'auth_oidc: cannot create user missing required claim %r in ' u'id_token (%r)', claim, id_token) @@ -184,25 +187,33 @@ class OIDCBackend(ModelBackend): user_ou = provider.ou save_user = False mappings = [] + context = id_token.as_dict(provider) + if need_user_info: + context.update(user_info or {}) + for claim_mapping in provider.claim_mappings.all(): claim = claim_mapping.claim if claim_mapping.idtoken_claim: source = id_token else: source = user_info - if claim not in source: + if claim not in source and not ('{{' in claim or '{%' in claim): continue - value = source.get(claim) + verified = False attribute = claim_mapping.attribute + if '{{' in claim or '{%' in claim: + template = Template(claim) + value = template.render(context=context) + # xxx missing verification logic for templated claims + else: + value = source.get(claim) + if claim_mapping.verified == models.OIDCClaimMapping.VERIFIED_CLAIM: + verified = bool(source.get(claim + '_verified', False)) if attribute == 'ou__slug' and value in ou_map: user_ou = ou_map[value] continue - if claim_mapping.verified == models.OIDCClaimMapping.VERIFIED_CLAIM: - verified = bool(source.get(claim + '_verified', False)) - elif claim_mapping.verified == models.OIDCClaimMapping.ALWAYS_VERIFIED: + if claim_mapping.verified == models.OIDCClaimMapping.ALWAYS_VERIFIED: verified = True - else: - verified = False mappings.append((attribute, value, verified)) # find en email in mappings diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py index 7cd1e8298..ed9f359ab 100644 --- a/tests/test_auth_oidc.py +++ b/tests/test_auth_oidc.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # authentic2 - versatile identity manager -# Copyright (C) 2010-2019 Entr'ouvert +# Copyright (C) 2010-2020 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published @@ -42,6 +42,7 @@ from authentic2_auth_oidc.utils import ( parse_id_token, IDToken, get_providers, has_providers, register_issuer, IDTokenError) from authentic2_auth_oidc.models import OIDCProvider, OIDCClaimMapping +from authentic2.models import Attribute from authentic2.models import AttributeValue from authentic2.utils import timestamp_from_datetime, last_authentication_event from authentic2.a2_rbac.utils import get_default_ou @@ -242,6 +243,7 @@ def oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, extra_id_token 'iat': timestamp_from_datetime(now()), 'aud': str(oidc_provider.client_id), 'exp': timestamp_from_datetime(now() + datetime.timedelta(seconds=10)), + 'name': 'doe', } if nonce: id_token['nonce'] = nonce @@ -290,6 +292,8 @@ def oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, extra_id_token 'given_name': 'John', 'family_name': 'Doe', 'email': 'john.doe@example.com', + 'phone_number': '0123456789', + 'nickname': 'Hefty', } if extra_user_info: user_info.update(extra_user_info) @@ -665,3 +669,73 @@ def test_invalid_kid(app, caplog, code, oidc_provider_rsa, with utils.check_log(caplog, message='Missing Key ID', levelname='WARNING'): with oidc_provider_mock(oidc_provider_rsa, oidc_provider_jwkset, code, nonce=nonce, kid=None): response = app.get(login_callback_url(oidc_provider_rsa), params={'code': code, 'state': query['state']}) + + +def test_templated_claim_mapping(app, caplog, code, oidc_provider, oidc_provider_jwkset): + get_providers.cache.clear() + has_providers.cache.clear() + + Attribute.objects.create( + name='pro_phone', + label='professonial phone', + kind='phone_number', + asked_on_registration=True + ) + # no default mapping + OIDCClaimMapping.objects.all().delete() + + OIDCClaimMapping.objects.create( + provider=oidc_provider, + attribute='username', + idtoken_claim=False, + claim='{{ given_name }} "{{ nickname }}" {{ family_name }}', + ) + OIDCClaimMapping.objects.create( + provider=oidc_provider, + attribute='pro_phone', + idtoken_claim=False, + claim='(prefix +33) {{ phone_number }}', + ) + OIDCClaimMapping.objects.create( + provider=oidc_provider, + attribute='email', + idtoken_claim=False, + claim='{{ given_name }}@foo.bar', + ) + # last one, with an idtoken claim + OIDCClaimMapping.objects.create( + provider=oidc_provider, + attribute='last_name', + idtoken_claim=True, + claim='{{ name|upper }}', + ) + # typo in template string + OIDCClaimMapping.objects.create( + provider=oidc_provider, + attribute='first_name', + idtoken_claim=True, + claim='{{ given_name', + ) + oidc_provider.save() + + User = get_user_model() + assert User.objects.count() == 0 + + response = app.get('/').maybe_follow() + response = response.click(oidc_provider.name) + location = urlparse.urlparse(response.location) + query = check_simple_qs(urlparse.parse_qs(location.query)) + nonce = app.session['auth_oidc'][query['state']]['request']['nonce'] + + with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce): + response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': query['state']}).maybe_follow() + + assert User.objects.count() == 1 + user = User.objects.first() + + assert user.username == 'John "Hefty" Doe' + assert user.attributes.pro_phone == '(prefix +33) 0123456789' + assert user.email == 'John@foo.bar' + assert user.last_name == 'DOE' + # typo in template string, no rendering + assert user.first_name == '{{ given_name'