From e7f4f294f92a858c45e224beec8f1b1aeb688980 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Sun, 13 Feb 2022 09:02:59 +0100
Subject: [PATCH] idp_oidc: make user info depend on profile choice during
 authz (#58556)

---
 src/authentic2/app_settings.py                |   4 +
 .../migrations/0031_profile_email.py          |  18 ++
 src/authentic2/custom_user/models.py          |   1 +
 src/authentic2_idp_oidc/app_settings.py       |   4 +
 src/authentic2_idp_oidc/apps.py               |   4 +-
 .../migrations/0015_auto_20220226_0824.py     |  42 +++
 src/authentic2_idp_oidc/models.py             |  10 +
 .../static/authentic2_idp_oidc/css/style.css  |  14 +
 .../authentic2_idp_oidc/authorization.html    |  33 ++-
 src/authentic2_idp_oidc/utils.py              |  41 ++-
 src/authentic2_idp_oidc/views.py              |  54 +++-
 tests/idp_oidc/conftest.py                    |   8 +
 tests/idp_oidc/test_user_profiles.py          | 262 ++++++++++++++++++
 13 files changed, 474 insertions(+), 21 deletions(-)
 create mode 100644 src/authentic2/custom_user/migrations/0031_profile_email.py
 create mode 100644 src/authentic2_idp_oidc/migrations/0015_auto_20220226_0824.py
 create mode 100644 src/authentic2_idp_oidc/static/authentic2_idp_oidc/css/style.css
 create mode 100644 tests/idp_oidc/test_user_profiles.py

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 34407de8d..b034583bf 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -333,6 +333,10 @@ default_settings = dict(
         default=250,
         definition='Maximum number of mails to send per period',
     ),
+    A2_USER_PROFILE_MANAGEMENT=Setting(
+        default=False,
+        definition='Activate user profiles for juridical entity management.',
+    ),
 )
 
 app_settings = AppSettings(default_settings)
diff --git a/src/authentic2/custom_user/migrations/0031_profile_email.py b/src/authentic2/custom_user/migrations/0031_profile_email.py
new file mode 100644
index 000000000..0c39c558d
--- /dev/null
+++ b/src/authentic2/custom_user/migrations/0031_profile_email.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.24 on 2022-02-15 11:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('custom_user', '0030_auto_20220222_1028'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='profile',
+            name='email',
+            field=models.EmailField(blank=True, max_length=254, verbose_name='profile email address'),
+        ),
+    ]
diff --git a/src/authentic2/custom_user/models.py b/src/authentic2/custom_user/models.py
index 02f262c4f..dfb28b02f 100644
--- a/src/authentic2/custom_user/models.py
+++ b/src/authentic2/custom_user/models.py
@@ -520,6 +520,7 @@ class Profile(models.Model):
         to=User, verbose_name=_('user'), related_name='subprofiles', on_delete=models.CASCADE
     )
     identifier = models.CharField(max_length=256, verbose_name=_('profile identifier'), default='')
+    email = models.EmailField(blank=True, max_length=254, verbose_name=_('profile email address'))
     data = JSONField(verbose_name=_('profile data'), null=True, blank=True)
 
     class Meta:
diff --git a/src/authentic2_idp_oidc/app_settings.py b/src/authentic2_idp_oidc/app_settings.py
index c923fdc8f..42a050208 100644
--- a/src/authentic2_idp_oidc/app_settings.py
+++ b/src/authentic2_idp_oidc/app_settings.py
@@ -79,6 +79,10 @@ class AppSettings:
             ],
         )
 
+    @property
+    def PROFILE_OVERRIDE_MAPPING(self):
+        return self._setting('PROFILE_OVERRIDE_MAPPING', {'email': 'email'})
+
 
 app_settings = AppSettings('A2_IDP_OIDC_')
 app_settings.__name__ = __name__
diff --git a/src/authentic2_idp_oidc/apps.py b/src/authentic2_idp_oidc/apps.py
index cc83f337b..38e7077f5 100644
--- a/src/authentic2_idp_oidc/apps.py
+++ b/src/authentic2_idp_oidc/apps.py
@@ -88,7 +88,7 @@ class AppConfig(django.apps.AppConfig):
         sub = smart_bytes(view.kwargs[lookup_url_kwarg])
         decrypted = utils.reverse_pairwise_sub(client, sub)
         if decrypted:
-            view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted).hex
+            view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted.split(b'#')[0]).hex
 
     def a2_hook_api_modify_serializer_after_validation(self, view, serializer):
         import uuid
@@ -111,7 +111,7 @@ class AppConfig(django.apps.AppConfig):
         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_uuid = uuid.UUID(bytes=decrypted.split(b'#')[0]).hex
                 new_known_uuids.append(new_known_uuid)
                 uuid_map[new_known_uuid] = u
             else:
diff --git a/src/authentic2_idp_oidc/migrations/0015_auto_20220226_0824.py b/src/authentic2_idp_oidc/migrations/0015_auto_20220226_0824.py
new file mode 100644
index 000000000..0e09a3674
--- /dev/null
+++ b/src/authentic2_idp_oidc/migrations/0015_auto_20220226_0824.py
@@ -0,0 +1,42 @@
+# Generated by Django 2.2.24 on 2022-02-26 07:24
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('custom_user', '0031_profile_email'),
+        ('authentic2_idp_oidc', '0014_auto_20201126_1812'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='oidcauthorization',
+            name='profile',
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to='custom_user.Profile',
+                verbose_name='profile',
+            ),
+        ),
+        migrations.AddField(
+            model_name='oidcclient',
+            name='activate_user_profiles',
+            field=models.BooleanField(
+                blank=True, default=False, verbose_name="activate users' juridical entity profiles management"
+            ),
+        ),
+        migrations.AddField(
+            model_name='oidccode',
+            name='profile',
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to='custom_user.Profile',
+                verbose_name='user selected profile',
+            ),
+        ),
+    ]
diff --git a/src/authentic2_idp_oidc/models.py b/src/authentic2_idp_oidc/models.py
index 0af559837..7c9487356 100644
--- a/src/authentic2_idp_oidc/models.py
+++ b/src/authentic2_idp_oidc/models.py
@@ -27,6 +27,7 @@ from django.utils.timezone import now
 from django.utils.translation import ugettext_lazy as _
 
 from authentic2.a2_rbac.models import OrganizationalUnit
+from authentic2.custom_user.models import Profile
 from authentic2.models import Service
 
 from . import app_settings, managers, utils
@@ -129,6 +130,11 @@ class OIDCClient(Service):
         default=ALGO_HMAC, choices=ALGO_CHOICES, verbose_name=_('IDToken signature algorithm')
     )
     has_api_access = models.BooleanField(verbose_name=_('has API access'), default=False)
+
+    activate_user_profiles = models.BooleanField(
+        verbose_name=_("activate users' juridical entity profiles management"), blank=True, default=False
+    )
+
     frontchannel_logout_uri = models.URLField(verbose_name=_('frontchannel logout URI'), blank=True)
     frontchannel_timeout = models.PositiveIntegerField(
         verbose_name=_('frontchannel timeout'), null=True, blank=True
@@ -240,6 +246,7 @@ class OIDCAuthorization(models.Model):
     client = GenericForeignKey('client_ct', 'client_id')
     user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
     scopes = models.TextField(blank=False, verbose_name=_('scopes'))
+    profile = models.ForeignKey(to=Profile, verbose_name=_('profile'), on_delete=models.CASCADE, null=True)
 
     # metadata
     created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
@@ -291,6 +298,9 @@ class OIDCCode(SessionMixin, models.Model):
     uuid = models.CharField(max_length=128, verbose_name=_('uuid'), default=generate_uuid)
     client = models.ForeignKey(to=OIDCClient, verbose_name=_('client'), on_delete=models.CASCADE)
     user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
+    profile = models.ForeignKey(
+        to=Profile, verbose_name=_('user selected profile'), null=True, on_delete=models.CASCADE
+    )
     scopes = models.TextField(verbose_name=_('scopes'))
     state = models.TextField(null=True, verbose_name=_('state'))
     nonce = models.TextField(null=True, verbose_name=_('nonce'))
diff --git a/src/authentic2_idp_oidc/static/authentic2_idp_oidc/css/style.css b/src/authentic2_idp_oidc/static/authentic2_idp_oidc/css/style.css
new file mode 100644
index 000000000..cc64f00c0
--- /dev/null
+++ b/src/authentic2_idp_oidc/static/authentic2_idp_oidc/css/style.css
@@ -0,0 +1,14 @@
+#profile-validation {
+	background: #f5f5f7;
+	padding-top: .3em;
+	margin-bottom: .3em;
+	border-radius: 4px;
+	color: black;
+}
+
+#profile-validation > .profile {
+	padding: .5em;
+	margin-top: .2em;
+	margin-bottom: .2em;
+	background: #d9d9d9;
+}
diff --git a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
index f6242fca4..e1589bd6e 100644
--- a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
+++ b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
@@ -1,10 +1,16 @@
 {% extends "authentic2/base-page.html" %}
-{% load i18n %}
+{% load i18n static %}
+
+{% block extra-top-head %}
+ {{ block.super }}
+<link rel="stylesheet" type="text/css" href="{% static 'authentic2_idp_oidc/css/style.css' %}"></link>
+{% endblock %}
+
 {% block content %}
 <h2>{% trans "Authentication access check" %}</h2>
 <form method="post" id="a2-oidc-authorization-form">
     <p>{% blocktrans with client_name=client.name %}Do you want to be authenticated on service {{ client_name }} ?{% endblocktrans %}</p>
-    {% if scopes %}
+    {% if needs_scope_validation %}
       <p>{% trans "The following informations will be sent to the service:" %}</p>
       <ul>
         {% for scope in scopes %}
@@ -16,6 +22,29 @@
         {% endfor %}
       </ul>
     {% endif %}
+    {% if needs_profile_validation %}
+     <div id="profile-validation">
+     <p>{% trans "Additionally, you may authenticate as owner of the following juridical entity management profile, which may change the aforementioned information." %}</p>
+      <div class="profile" id="profile-validation-none">
+        <input type="radio" id="_none" name="profile-validation" value="" checked>
+        <label for="_none">{% trans "Keep my default user profile." %}</label>
+      </div>
+      {% for profile in user.subprofiles.all %}
+        <div class="profile" id="profile-validation-{{ profile.id }}">
+          <input type="radio" id="{{ profile.id }}" name="profile-validation" value="{{ profile.id }}">
+          <label for="{{ profile.id }}">
+          {% with identifier=profile.identifier type_name=profile.profile_type.name %}
+            {% trans "Unknown entity" as fallback_identifier %}
+            {% blocktrans %}Profile of type {{ type_name }}: {% endblocktrans %}
+            {% firstof identifier fallback_identifier %}<br />
+          {% endwith %}
+          {% if profile.email %}
+            {% blocktrans with email=profile.email %}Email address: {{ email }}{% endblocktrans %}<br />
+          {% endif %}</label>
+        </div>
+      {% endfor %}
+     </div>
+    {% endif %}
     {% csrf_token %}
     <p class="a2-oidc-authorization-form--do-not-ask-again">
       <label for="id_do_not_ask_again"><input id="id_do_not_ask_again" type="checkbox" name="do_not_ask_again" value="on"/><span>{% trans "Do not ask again" %}</span></label>
diff --git a/src/authentic2_idp_oidc/utils.py b/src/authentic2_idp_oidc/utils.py
index 1a66bf451..7e3038223 100644
--- a/src/authentic2_idp_oidc/utils.py
+++ b/src/authentic2_idp_oidc/utils.py
@@ -120,9 +120,9 @@ def url_domain(url):
     return urllib.parse.urlparse(url).netloc.split(':')[0]
 
 
-def make_sub(client, user):
+def make_sub(client, user, profile=None):
     if client.identifier_policy in (client.POLICY_PAIRWISE, client.POLICY_PAIRWISE_REVERSIBLE):
-        return make_pairwise_sub(client, user)
+        return make_pairwise_sub(client, user, profile=profile)
     elif client.identifier_policy == client.POLICY_UUID:
         return force_text(user.uuid)
     elif client.identifier_policy == client.POLICY_EMAIL:
@@ -131,36 +131,43 @@ def make_sub(client, user):
         raise NotImplementedError
 
 
-def make_pairwise_sub(client, user):
+def make_pairwise_sub(client, user, profile=None):
     '''Make a pairwise sub'''
     if client.identifier_policy == client.POLICY_PAIRWISE:
-        return make_pairwise_unreversible_sub(client, user)
+        return make_pairwise_unreversible_sub(client, user, profile=profile)
     elif client.identifier_policy == client.POLICY_PAIRWISE_REVERSIBLE:
-        return make_pairwise_reversible_sub(client, user)
+        return make_pairwise_reversible_sub(client, user, profile=profile)
     else:
         raise NotImplementedError('unknown pairwise client.identifier_policy %s' % client.identifier_policy)
 
 
-def make_pairwise_unreversible_sub(client, user):
+def make_pairwise_unreversible_sub(client, user, profile=None):
     sector_identifier = client.get_sector_identifier()
     sub = sector_identifier + str(user.uuid) + settings.SECRET_KEY
+    if profile:
+        sub += str(profile.id)
     sub = base64.b64encode(hashlib.sha256(sub.encode('utf-8')).digest())
     return sub.decode('utf-8')
 
 
-def make_pairwise_reversible_sub(client, user):
-    return make_pairwise_reversible_sub_from_uuid(client, user.uuid)
+def make_pairwise_reversible_sub(client, user, profile=None):
+    return make_pairwise_reversible_sub_from_uuid(client, user.uuid, profile=profile)
 
 
-def make_pairwise_reversible_sub_from_uuid(client, user_uuid):
+def make_pairwise_reversible_sub_from_uuid(client, user_uuid, profile=None):
     try:
         identifier = uuid.UUID(user_uuid).bytes
     except ValueError:
         return None
     sector_identifier = client.get_sector_identifier()
-    return crypto.aes_base64url_deterministic_encrypt(
-        settings.SECRET_KEY.encode('utf-8'), identifier, sector_identifier
-    ).decode('utf-8')
+    cipher_args = [
+        settings.SECRET_KEY.encode('utf-8'),
+        identifier,
+        sector_identifier,
+    ]
+    if profile:
+        cipher_args[1] += '#%s' % str(profile.id)
+    return crypto.aes_base64url_deterministic_encrypt(*cipher_args).decode('utf-8')
 
 
 def reverse_pairwise_sub(client, sub):
@@ -183,11 +190,11 @@ def clean_claim_value(value):
     return str(value)
 
 
-def create_user_info(request, client, user, scope_set, id_token=False):
+def create_user_info(request, client, user, scope_set, id_token=False, profile=None):
     '''Create user info dictionary'''
     user_info = {}
     if 'openid' in scope_set:
-        user_info['sub'] = make_sub(client, user)
+        user_info['sub'] = make_sub(client, user, profile=profile)
     attributes = get_attributes(
         {
             'user': user,
@@ -240,6 +247,12 @@ def create_user_info(request, client, user, scope_set, id_token=False):
             ]:
                 default_value = ''
             user_info[claim.name] = default_value
+    if profile:
+        for attr, userinfo_key in app_settings.PROFILE_OVERRIDE_MAPPING.items():
+            if getattr(profile, attr, None) and userinfo_key in user_info:
+                user_info[userinfo_key] = getattr(profile, attr)
+        user_info['profile_identifier'] = profile.identifier
+        user_info['profile_type'] = profile.profile_type.slug
     hooks.call_hooks('idp_oidc_modify_user_info', client, user, scope_set, user_info)
     return user_info
 
diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py
index 1269e7c38..05d5f265b 100644
--- a/src/authentic2_idp_oidc/views.py
+++ b/src/authentic2_idp_oidc/views.py
@@ -38,6 +38,7 @@ from ratelimit.utils import is_ratelimited
 from authentic2 import app_settings as a2_app_settings
 from authentic2 import hooks
 from authentic2.a2_rbac.models import OrganizationalUnit
+from authentic2.custom_user.models import Profile
 from authentic2.decorators import setting_enabled
 from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
 from authentic2.utils.misc import last_authentication_event, login_require, make_url, redirect
@@ -359,6 +360,9 @@ def authorize_for_client(request, client, redirect_uri):
 
     iat = now()  # iat = issued at
 
+    needs_profile_validation = False
+    needs_scope_validation = False
+    profile = None
     if client.authorization_mode != client.AUTHORIZATION_MODE_NONE or 'consent' in prompt:
         # authorization by user is mandatory, as per local configuration or per explicit request by
         # the RP
@@ -379,12 +383,35 @@ def authorize_for_client(request, client, redirect_uri):
         else:
             qs = qs.filter(expired__gte=iat)
         authorized_scopes = set()
+        authorized_profile = None
         for authorization in qs:
             authorized_scopes |= authorization.scope_set()
+            # load first authorized profile
+            if not authorized_profile and authorization.profile:
+                authorized_profile = authorized_profile
+        if (
+            request.user.subprofiles.count()
+            and not authorized_profile
+            and client.activate_user_profiles
+            and a2_app_settings.A2_USER_PROFILE_MANAGEMENT
+        ):
+            needs_profile_validation = True
+        else:
+            profile = authorized_profile
         if (authorized_scopes & scopes) < scopes:
+            needs_scope_validation = True
+        if needs_scope_validation or needs_profile_validation:
             if 'none' in prompt:
                 raise ConsentRequired(_('Consent is required but prompt parameter is "none"'))
             if request.method == 'POST':
+                if request.POST.get('profile-validation', ''):
+                    try:
+                        profile = Profile.objects.get(
+                            user=request.user,
+                            id=request.POST['profile-validation'],
+                        )
+                    except Profile.DoesNotExist:
+                        pass
                 if 'accept' in request.POST:
                     if 'do_not_ask_again' in request.POST:
                         pk_to_deletes = []
@@ -394,6 +421,7 @@ def authorize_for_client(request, client, redirect_uri):
                                 pk_to_deletes.append(authorization.pk)
                         auth_manager.create(
                             user=request.user,
+                            profile=profile,
                             scopes=' '.join(sorted(scopes)),
                             expired=iat + datetime.timedelta(days=365),
                         )
@@ -417,6 +445,8 @@ def authorize_for_client(request, client, redirect_uri):
                     request,
                     'authentic2_idp_oidc/authorization.html',
                     {
+                        'needs_profile_validation': needs_profile_validation,
+                        'needs_scope_validation': needs_scope_validation,
                         'client': client,
                         'scopes': scopes - {'openid'},
                     },
@@ -425,6 +455,7 @@ def authorize_for_client(request, client, redirect_uri):
         code = models.OIDCCode.objects.create(
             client=client,
             user=request.user,
+            profile=profile,
             scopes=' '.join(scopes),
             state=state,
             nonce=nonce,
@@ -444,6 +475,14 @@ def authorize_for_client(request, client, redirect_uri):
         response = redirect(request, redirect_uri, params=params, resolve=False)
     else:
         need_access_token = 'token' in response_type.split()
+        if 'profile-validation' in request.POST:
+            try:
+                profile = Profile.objects.get(
+                    id=request.POST.get('profile-validation', None),
+                    user=request.user,
+                )
+            except Profile.DoesNotExist:
+                pass
         if need_access_token:
             if client.access_token_duration is None:
                 expires_in = datetime.timedelta(seconds=request.session.get_expiry_age())
@@ -461,7 +500,9 @@ def authorize_for_client(request, client, redirect_uri):
         acr = '0'
         if nonce is not None and last_auth.get('nonce') == nonce:
             acr = '1'
-        id_token = utils.create_user_info(request, client, request.user, scopes, id_token=True)
+        id_token = utils.create_user_info(
+            request, client, request.user, scopes, id_token=True, profile=profile
+        )
         exp = iat + idtoken_duration(client)
         id_token.update(
             {
@@ -721,12 +762,19 @@ def tokens_from_authz_code(request):
     ):
         acr = '1'
     # prefill id_token with user info
-    id_token = utils.create_user_info(request, client, oidc_code.user, oidc_code.scope_set(), id_token=True)
+    id_token = utils.create_user_info(
+        request,
+        client,
+        oidc_code.user,
+        oidc_code.scope_set(),
+        id_token=True,
+        profile=oidc_code.profile,
+    )
     exp = start + idtoken_duration(client)
     id_token.update(
         {
             'iss': utils.get_issuer(request),
-            'sub': utils.make_sub(client, oidc_code.user),
+            'sub': utils.make_sub(client, oidc_code.user, profile=oidc_code.profile),
             'aud': client.client_id,
             'exp': int(exp.timestamp()),
             'iat': int(start.timestamp()),
diff --git a/tests/idp_oidc/conftest.py b/tests/idp_oidc/conftest.py
index ea82d11b7..f9a9b2798 100644
--- a/tests/idp_oidc/conftest.py
+++ b/tests/idp_oidc/conftest.py
@@ -75,6 +75,14 @@ def oidc_settings(settings, jwkset):
     return settings
 
 
+@pytest.fixture
+def profile_settings(settings, jwkset):
+    settings.A2_IDP_OIDC_JWKSET = jwkset
+    settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT = '100/m'
+    settings.A2_USER_PROFILE_MANAGEMENT = True
+    return settings
+
+
 def make_client(app, superuser, params=None):
     Attribute.objects.create(
         name='cityscape_image',
diff --git a/tests/idp_oidc/test_user_profiles.py b/tests/idp_oidc/test_user_profiles.py
new file mode 100644
index 000000000..5718c10c0
--- /dev/null
+++ b/tests/idp_oidc/test_user_profiles.py
@@ -0,0 +1,262 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2010-2022 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
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# 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 base64
+import json
+import urllib.parse
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+from django.utils.encoding import force_text
+from django.utils.timezone import now
+from jwcrypto.jwk import JWK
+from jwcrypto.jwt import JWT
+
+from authentic2.custom_user.models import Profile, ProfileType
+from authentic2.utils.misc import make_url
+from authentic2_idp_oidc.models import OIDCAccessToken, OIDCCode
+from authentic2_idp_oidc.utils import make_sub
+
+from .. import utils
+from .conftest import client_authentication_headers
+
+User = get_user_model()
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def profile_user():
+    user = User.objects.create(
+        first_name='Foo',
+        last_name='Bar',
+        username='foobar',
+        email='foobar@example.org',
+    )
+    profile_type_manager = ProfileType.objects.create(
+        name='One Manager Type',
+        slug='one-manager-type',
+    )
+    profile_type_delegate = ProfileType.objects.create(
+        name='One Delegate Type',
+        slug='one-delegate-type',
+    )
+    Profile.objects.create(
+        user=user,
+        profile_type=profile_type_manager,
+        identifier='Entity 789',
+        email='manager@example789.org',
+    )
+    Profile.objects.create(
+        user=user,
+        profile_type=profile_type_delegate,
+        identifier='Entity 1011',
+        email='delegate@example1011.org',
+    )
+    user.clear_password = 'foobar'
+    user.set_password('foobar')
+    user.save()
+    return user
+
+
+def test_admin_base_models(app, superuser, simple_user, profile_settings):
+    url = reverse('admin:custom_user_profiletype_add')
+    assert ProfileType.objects.count() == 0
+    response = utils.login(app, superuser, path=url)
+    response.form.set('name', 'Manager')
+    response.form.set('slug', 'manager')
+    response = response.form.submit(name='_save').follow()
+    assert ProfileType.objects.count() == 1
+
+    response = app.get(url)
+    response.form.set('name', 'Delegate')
+    response.form.set('slug', 'delegate')
+    response = response.form.submit(name='_save').follow()
+    assert ProfileType.objects.count() == 2
+
+    url = reverse('admin:custom_user_profile_add')
+    assert Profile.objects.count() == 0
+    response = app.get(url)
+    response.form.set('user', simple_user.id)
+    response.form.set('profile_type', ProfileType.objects.first().pk)
+    response.form.set('email', 'john.doe@example.org')
+    response.form.set('identifier', 'Entity 0123')
+    response = response.form.submit(name='_save').follow()
+    assert Profile.objects.count() == 1
+
+    response = app.get(url)
+    response.form.set('user', simple_user.id)
+    response.form.set('profile_type', ProfileType.objects.last().pk)
+    response.form.set('email', 'john.doe@anotherexample.org')
+    response.form.set('identifier', 'Entity 5678')
+    response = response.form.submit(name='_save').follow()
+    assert Profile.objects.count() == 2
+
+
+def test_login_profiles_absent(app, oidc_client, simple_user, profile_settings):
+    redirect_uri = oidc_client.redirect_uris.split()[0]
+    oidc_client.activate_user_profiles = True
+    oidc_client.save()
+    params = {
+        'client_id': oidc_client.client_id,
+        'scope': 'openid profile email',
+        'redirect_uri': redirect_uri,
+        'state': 'xxx',
+        'nonce': 'yyy',
+        'login_hint': 'backoffice john@example.com',
+        'response_type': 'code',
+    }
+    authorize_url = make_url('oidc-authorize', params=params)
+    utils.login(app, simple_user)
+    response = app.get(authorize_url)
+    assert 'a2-oidc-authorization-form' in response.text
+    # not interface changes for users without a profile
+    assert not 'profile-validation-' in response.text
+
+
+def test_login_profile_selection(app, oidc_client, profile_user, profile_settings):
+    oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
+    oidc_client.activate_user_profiles = True
+    oidc_client.save()
+    redirect_uri = oidc_client.redirect_uris.split()[0]
+    params = {
+        'client_id': oidc_client.client_id,
+        'scope': 'openid profile email',
+        'redirect_uri': redirect_uri,
+        'state': 'xxx',
+        'nonce': 'yyy',
+        'login_hint': 'backoffice john@example.com',
+        'response_type': 'code',
+    }
+    assert profile_user.subprofiles.count() == 2
+
+    authorize_url = make_url('oidc-authorize', params=params)
+    utils.login(app, profile_user)
+    response = app.get(authorize_url)
+    assert 'a2-oidc-authorization-form' in response.text
+    assert 'profile-validation-' in response.text
+    response.form.set('profile-validation', profile_user.subprofiles.first().id)
+    response = response.form.submit('accept')
+    assert OIDCCode.objects.count() == 1
+    code = OIDCCode.objects.get()
+    assert code.client == oidc_client
+    assert code.user == profile_user
+    assert code.profile == profile_user.subprofiles.first()
+    assert code.scope_set() == set('openid profile email'.split())
+    assert code.state == 'xxx'
+    assert code.nonce == 'yyy'
+    assert code.redirect_uri == redirect_uri
+    assert code.session_key == app.session.session_key
+    assert code.auth_time <= now()
+    assert code.expired >= now()
+    assert response['Location'].startswith(redirect_uri)
+    location = urllib.parse.urlparse(response['Location'])
+    query = urllib.parse.parse_qs(location.query)
+    assert set(query.keys()) == {'code', 'state'}
+    assert query['code'] == [code.uuid]
+    code = query['code'][0]
+    assert query['state'] == ['xxx']
+
+    token_url = make_url('oidc-token')
+    response = app.post(
+        token_url,
+        params={
+            'grant_type': 'authorization_code',
+            'code': code,
+            'redirect_uri': oidc_client.redirect_uris.split()[0],
+        },
+        headers=client_authentication_headers(oidc_client),
+    )
+    assert 'error' not in response.json
+    assert 'access_token' in response.json
+    assert 'expires_in' in response.json
+    assert 'id_token' in response.json
+    assert response.json['token_type'] == 'Bearer'
+    access_token = response.json['access_token']
+    assert access_token
+    id_token = response.json['id_token']
+    k = base64.b64encode(oidc_client.client_secret.encode('utf-8'))
+    key = JWK(kty='oct', k=force_text(k))
+    algs = ['HS256']
+    jwt = JWT(jwt=id_token, key=key, algs=algs)
+    claims = json.loads(jwt.claims)
+
+    # check subject identifier substitution:
+    assert claims['sub'] != make_sub(oidc_client, profile_user)
+    assert claims['sub'] == make_sub(oidc_client, profile_user, profile=profile_user.subprofiles.first())
+
+    # check email substitution
+    assert claims['email'] != profile_user.email
+    assert claims['email'] == profile_user.subprofiles.first().email
+
+    # check additional profile claims
+    assert claims['profile_identifier'] == profile_user.subprofiles.first().identifier
+    assert claims['profile_type'] == profile_user.subprofiles.first().profile_type.slug
+
+
+def test_login_implicit(app, oidc_client, profile_user, profile_settings):
+    oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
+    oidc_client.authorization_flow = oidc_client.FLOW_IMPLICIT
+    oidc_client.activate_user_profiles = True
+    oidc_client.save()
+    redirect_uri = oidc_client.redirect_uris.split()[0]
+    params = {
+        'client_id': oidc_client.client_id,
+        'scope': 'openid profile email',
+        'redirect_uri': redirect_uri,
+        'state': 'xxx',
+        'nonce': 'yyy',
+        'login_hint': 'backoffice john@example.com',
+        'response_type': 'token id_token',
+    }
+
+    assert profile_user.subprofiles.count() == 2
+    authorize_url = make_url('oidc-authorize', params=params)
+    utils.login(app, profile_user)
+    response = app.get(authorize_url)
+    assert 'a2-oidc-authorization-form' in response.text
+    assert 'profile-validation-' in response.text
+    response.form.set('profile-validation', profile_user.subprofiles.first().id)
+    response = response.form.submit('accept')
+    location = urllib.parse.urlparse(response['Location'])
+    assert location.fragment
+    query = urllib.parse.parse_qs(location.fragment)
+    assert OIDCAccessToken.objects.count() == 1
+    access_token = OIDCAccessToken.objects.get()
+    assert set(query.keys()) == {'access_token', 'token_type', 'expires_in', 'id_token', 'state'}
+    assert query['access_token'] == [access_token.uuid]
+    assert query['token_type'] == ['Bearer']
+    assert query['state'] == ['xxx']
+    access_token = query['access_token'][0]
+    id_token = query['id_token'][0]
+    k = base64.b64encode(oidc_client.client_secret.encode('utf-8'))
+    key = JWK(kty='oct', k=force_text(k))
+    algs = ['HS256']
+    jwt = JWT(jwt=id_token, key=key, algs=algs)
+    claims = json.loads(jwt.claims)
+
+    # check subject identifier substitution:
+    assert claims['sub'] != make_sub(oidc_client, profile_user)
+    assert claims['sub'] == make_sub(oidc_client, profile_user, profile=profile_user.subprofiles.first())
+
+    # check email substitution
+    assert claims['email'] != profile_user.email
+    assert claims['email'] == profile_user.subprofiles.first().email
+
+    # check additional profile claims
+    assert claims['profile_identifier'] == profile_user.subprofiles.first().identifier
+    assert claims['profile_type'] == profile_user.subprofiles.first().profile_type.slug
-- 
2.35.1

