From 73f0d09963632353264d7fb520ba20d25f68778a Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Wed, 1 Jun 2022 15:07:26 +0200
Subject: [PATCH] idp_oidc: perform claim resolution when api is called by a
 client (#65877)

---
 src/authentic2/api_views.py     | 13 +++++++
 src/authentic2_idp_oidc/apps.py | 65 +++++++++++++++++++++++++-------
 tests/idp_oidc/test_api.py      | 66 ++++++++++++++++++++++++++++++++-
 3 files changed, 129 insertions(+), 15 deletions(-)

diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index e126614d1..d97edf5a8 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -826,6 +826,19 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin
         hooks.call_hooks('api_modify_response', self, 'synchronization', data)
         return Response(data)
 
+    def list(self, request, *args, **kwargs):
+        queryset = self.filter_queryset(self.get_queryset())
+
+        page = self.paginate_queryset(queryset)
+        if page is not None:
+            serializer = self.get_serializer(page, many=True)
+            hooks.call_hooks('api_modify_serializer_after_validation', self, serializer)
+            return self.get_paginated_response(serializer.data)
+
+        serializer = self.get_serializer(queryset, many=True)
+        hooks.call_hooks('api_modify_serializer_after_validation', self, serializer)
+        return Response(serializer.data)
+
     @action(
         detail=True,
         methods=['post'],
diff --git a/src/authentic2_idp_oidc/apps.py b/src/authentic2_idp_oidc/apps.py
index cc83f337b..66062933b 100644
--- a/src/authentic2_idp_oidc/apps.py
+++ b/src/authentic2_idp_oidc/apps.py
@@ -14,6 +14,8 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import copy
+
 import django.apps
 from django.template.loader import render_to_string
 from django.utils.encoding import smart_bytes
@@ -49,9 +51,13 @@ class AppConfig(django.apps.AppConfig):
     # implement translation of encrypted pairwise identifiers when and OIDC Client is using the
     # A2 API
     def a2_hook_api_modify_serializer(self, view, serializer):
+        from django.utils.timezone import utc
         from rest_framework import serializers
 
+        from authentic2.utils.template import Template
+
         from . import utils
+        from .models import OIDCClaim
 
         if hasattr(view.request.user, 'oidc_client'):
             client = view.request.user.oidc_client
@@ -63,6 +69,24 @@ class AppConfig(django.apps.AppConfig):
                 serializer.get_oidc_uuid = get_oidc_uuuid
                 serializer.fields['uuid'] = serializers.SerializerMethodField(method_name='get_oidc_uuid')
 
+            # /api/users/ returned content needs oidc claim resolution
+            if view.__class__.__name__ == 'UsersAPI' and 'claim_resolution' in view.request.GET:
+                # use deepcopy to prevent overwrite of field.field_name
+                # see: https://github.com/encode/django-rest-framework/blob/bce9df9b5e0f54a6076519835393fea59accb40c/rest_framework/utils/serializer_helpers.py#L169
+                serializer.fields['sub'] = copy.deepcopy(serializer.fields['uuid'])
+                del serializer.fields['is_superuser']
+                del serializer.fields['is_staff']
+                del serializer.fields['password']
+                # forbid modification of email trough POST/PATCH/PUT
+                if serializer.instance:
+                    serializer.fields['email'].read_only = True
+                for claim in OIDCClaim.objects.filter(client=client):
+                    if claim.name in serializer.fields:
+                        continue
+                    serializer.fields[claim.name] = serializers.CharField(
+                        read_only=True,
+                    )
+
     @classmethod
     def get_oidc_client(cls, view):
         request = view.request
@@ -93,11 +117,14 @@ class AppConfig(django.apps.AppConfig):
     def a2_hook_api_modify_serializer_after_validation(self, view, serializer):
         import uuid
 
+        from authentic2.utils.template import Template
+
         from . import utils
+        from .models import OIDCClaim
 
         if view.__class__.__name__ != 'UsersAPI':
             return
-        if serializer.__class__.__name__ != 'SynchronizationSerializer':
+        if serializer.__class__.__name__ not in ('SynchronizationSerializer', 'ListSerializer'):
             return
         request = view.request
         if not hasattr(request.user, 'oidc_client'):
@@ -105,19 +132,29 @@ class AppConfig(django.apps.AppConfig):
         client = request.user.oidc_client
         if client.identifier_policy != client.POLICY_PAIRWISE_REVERSIBLE:
             return
-        new_known_uuids = []
-        uuid_map = request.uuid_map = {}
-        request.unknown_uuids = []
-        for u in serializer.validated_data['known_uuids']:
-            decrypted = utils.reverse_pairwise_sub(client, smart_bytes(u))
-            if decrypted:
-                new_known_uuid = uuid.UUID(bytes=decrypted).hex
-                new_known_uuids.append(new_known_uuid)
-                uuid_map[new_known_uuid] = u
-            else:
-                request.unknown_uuids.append(u)
-            # undecipherable sub are just not checked at all
-        serializer.validated_data['known_uuids'] = new_known_uuids
+        if serializer.__class__.__name__ == 'SynchronizationSerializer':
+            new_known_uuids = []
+            request.unknown_uuids = []
+            uuid_map = request.uuid_map = {}
+            for u in serializer.validated_data['known_uuids']:
+                decrypted = utils.reverse_pairwise_sub(client, smart_bytes(u))
+                if decrypted:
+                    new_known_uuid = uuid.UUID(bytes=decrypted).hex
+                    new_known_uuids.append(new_known_uuid)
+                    uuid_map[new_known_uuid] = u
+                else:
+                    # undecipherable sub are just not checked at all
+                    request.unknown_uuids.append(u)
+            serializer.validated_data['known_uuids'] = new_known_uuids
+        elif serializer.__class__.__name__ == 'ListSerializer' and 'claim_resolution' in view.request.GET:
+            for user_dict in serializer.data:
+                context = user_dict.copy()
+                for claim in OIDCClaim.objects.filter(client=client):
+                    value = claim.value
+                    if claim.value and ('{{' in claim.value or '{%' in claim.value):
+                        template = Template(claim.value)
+                        value = template.render(context=context)
+                    user_dict[claim.name] = value
 
     def a2_hook_api_modify_response(self, view, method_name, data):
         """Reverse mapping applied in a2_hook_api_modify_serializer_after_validation using the
diff --git a/tests/idp_oidc/test_api.py b/tests/idp_oidc/test_api.py
index fcfdf5c7c..8d984bbff 100644
--- a/tests/idp_oidc/test_api.py
+++ b/tests/idp_oidc/test_api.py
@@ -14,8 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import random
+
+from django.utils.timezone import now
+
 from authentic2.custom_user.models import User
-from authentic2_idp_oidc.models import OIDCClient
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
 from authentic2_idp_oidc.utils import make_sub
 
 
@@ -39,3 +43,63 @@ def test_api_synchronization(app, oidc_client):
     if status == 200:
         assert response.json['result'] == 1
         assert set(response.json['unknown_uuids']) == deleted_subs
+
+
+def test_api_users_list_claim_resolution(app, oidc_client):
+    oidc_client.has_api_access = True
+    oidc_client.identifier_policy = OIDCClient.POLICY_PAIRWISE
+    oidc_client.save()
+
+    # default claims are boring, create custom templated ones
+    OIDCClaim.objects.all().delete()
+    OIDCClaim.objects.create(
+        name='given_name',
+        value='Templated {{ first_name }}',
+        scopes='profile',
+        client=oidc_client,
+    )
+    OIDCClaim.objects.create(
+        name='family_name',
+        value='Templated {{ last_name }}',
+        scopes='profile',
+        client=oidc_client,
+    )
+    OIDCClaim.objects.create(
+        name='email',
+        value='{{ last_name }}@templated.nowhere.null',
+        scopes='email',
+        client=oidc_client,
+    )
+
+    users = [User.objects.create(username=f'user-{i}', last_name=f'Name-{i}') for i in range(10)]
+    pre_modification = now().strftime('%Y-%m-%dT%H:%M:%S')
+    for count, user in enumerate(users):
+        user.first_name = f'User {count}'
+        user.save()
+
+    app.authorization = ('Basic', (oidc_client.client_id, oidc_client.client_secret))
+    app.get(
+        f'/api/users/?modified__gt={pre_modification}&claim_resolution',
+        status=401,
+    )
+
+    oidc_client.identifier_policy = OIDCClient.POLICY_PAIRWISE_REVERSIBLE
+    oidc_client.save()
+
+    response = app.get(
+        f'/api/users/?modified__gt={pre_modification}&claim_resolution',
+        status=200,
+    )
+
+    for user_dict in random.choices(response.json['results'], k=3):
+        assert user_dict['last_name']
+        assert user_dict['family_name'].startswith('Templated')
+        assert user_dict['family_name'].endswith(user_dict['last_name'])
+
+        assert user_dict['first_name']
+        assert user_dict['given_name'].startswith('Templated')
+        assert user_dict['given_name'].endswith(user_dict['first_name'])
+
+        assert user_dict['email']
+        assert user_dict['email'].startswith(user_dict['last_name'])
+        assert user_dict['email'].endswith('@templated.nowhere.null')
-- 
2.36.1

