From 25a7c66673c40b927ea69bdae5fb1e47971457ce Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Fri, 23 Sep 2022 11:41:52 +0200
Subject: [PATCH 2/2] idp_oidc: serve user profile information in UserInfo
 endpoint (#69416)

---
 src/authentic2_idp_oidc/views.py     |  30 +++++-
 tests/idp_oidc/test_user_profiles.py | 136 ++++++++++++++++++++++++++-
 2 files changed, 160 insertions(+), 6 deletions(-)

diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py
index 4a8fa1a93..dc5bb9d75 100644
--- a/src/authentic2_idp_oidc/views.py
+++ b/src/authentic2_idp_oidc/views.py
@@ -492,6 +492,7 @@ def authorize_for_client(request, client, redirect_uri):
                 scopes=' '.join(scopes),
                 session_key=request.session.session_key,
                 expired=expired,
+                profile=profile,
             )
         acr = '0'
         if nonce is not None and last_auth.get('nonce') == nonce:
@@ -634,6 +635,7 @@ def idtoken_from_user_credential(request):
         )
     username = request.POST.get('username')
     scope = request.POST.get('scope')
+    profile_id = request.POST.get('profile')
 
     # scope is ignored, we used the configured scope
 
@@ -695,11 +697,28 @@ def idtoken_from_user_credential(request):
         expires_in = datetime.timedelta(seconds=app_settings.ACCESS_TOKEN_DURATION)
     else:
         expires_in = client.access_token_duration
+
+    profile = None
+    if profile_id:
+        if not client.activate_user_profiles:
+            raise AccessDenied(
+                _('User profile requested yet client does not manage profiles.'), client=client
+            )
+        try:
+            profile = Profile.objects.get(id=profile_id, user=user)
+        except Profile.DoesNotExist:
+            raise AccessDenied(_('Invalid profile'), client=client)
+
     access_token = models.OIDCAccessToken.objects.create(
-        client=client, user=user, scopes=' '.join(scopes), session_key='', expired=iat + expires_in
+        client=client,
+        user=user,
+        scopes=' '.join(scopes),
+        session_key='',
+        expired=iat + expires_in,
+        profile=profile,
     )
     # make id_token
-    id_token = utils.create_user_info(request, client, user, scopes, id_token=True)
+    id_token = utils.create_user_info(request, client, user, scopes, profile=profile, id_token=True)
     exp = iat + idtoken_duration(client)
     id_token.update(
         {
@@ -748,6 +767,7 @@ def tokens_from_authz_code(request):
         scopes=oidc_code.scopes,
         session_key=oidc_code.session_key,
         expired=expired,
+        profile=oidc_code.profile,
     )
     start = now()
     acr = '0'
@@ -835,7 +855,11 @@ def user_info(request, *args, **kwargs):
     try:
         access_token = authenticate_access_token(request)
         user_info = utils.create_user_info(
-            request, access_token.client, access_token.user, access_token.scope_set()
+            request,
+            access_token.client,
+            access_token.user,
+            access_token.scope_set(),
+            profile=access_token.profile,
         )
         return JsonResponse(user_info)
     except OIDCException as e:
diff --git a/tests/idp_oidc/test_user_profiles.py b/tests/idp_oidc/test_user_profiles.py
index 469b44d7c..4d3e3712d 100644
--- a/tests/idp_oidc/test_user_profiles.py
+++ b/tests/idp_oidc/test_user_profiles.py
@@ -30,11 +30,19 @@ 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 get_jwkset, make_pairwise_sub, make_sub, reverse_pairwise_sub
+from authentic2_idp_oidc.models import OIDCAccessToken, OIDCClient, OIDCCode
+from authentic2_idp_oidc.utils import (
+    base64url,
+    get_first_ec_sig_key,
+    get_first_rsa_sig_key,
+    get_jwkset,
+    make_pairwise_sub,
+    make_sub,
+    reverse_pairwise_sub,
+)
 
 from .. import utils
-from .conftest import client_authentication_headers
+from .conftest import bearer_authentication_headers, client_authentication_headers
 
 User = get_user_model()
 
@@ -219,6 +227,17 @@ def test_login_profile_selection(app, oidc_client, profile_user, profile_setting
     assert claims['entity_data_au'] == 'ie'
     assert claims['entity_data_ts'] == 'rn'
 
+    user_info_url = make_url('oidc-user-info')
+    response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
+
+    assert response.json['profile_type'] == 'one-delegate-type'
+    assert response.json['profile_identifier'] == 'Entity 1011'
+    assert response.json['email'] == 'delegate@example1011.org'
+    assert response.json['sub'] == claims['sub']
+    assert response.json['entity_name'] == 'Foobar'
+    assert response.json['entity_data_au'] == 'ie'
+    assert response.json['entity_data_ts'] == 'rn'
+
 
 def test_login_implicit(app, oidc_client, profile_user, profile_settings):
     oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
@@ -277,6 +296,17 @@ def test_login_implicit(app, oidc_client, profile_user, profile_settings):
     assert claims['entity_data_au'] == 'ie'
     assert claims['entity_data_ts'] == 'rn'
 
+    user_info_url = make_url('oidc-user-info')
+    response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
+
+    assert response.json['profile_type'] == 'one-delegate-type'
+    assert response.json['profile_identifier'] == 'Entity 1011'
+    assert response.json['email'] == 'delegate@example1011.org'
+    assert response.json['sub'] == claims['sub']
+    assert response.json['entity_name'] == 'Foobar'
+    assert response.json['entity_data_au'] == 'ie'
+    assert response.json['entity_data_ts'] == 'rn'
+
 
 def test_login_profile_reversible_sub(app, oidc_client, profile_user, profile_settings):
     oidc_client.idtoken_algo = oidc_client.ALGO_EC
@@ -428,3 +458,103 @@ def test_modify_user_info_hook(app, oidc_client, profile_settings, profile_user,
     assert claims['email'] == 'def@ad.dre.ss'
     assert claims['profile'] == profile_user.profiles.first().id
     assert claims['customclaim'] == 'whatever'
+
+
+def test_profile_selection_user_credentials_grant(app, oidc_client, admin, simple_user):
+    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
+    oidc_client.scope = 'openid email'
+    oidc_client.save()
+    token_url = make_url('oidc-token')
+
+    if oidc_client.idtoken_algo == OIDCClient.ALGO_HMAC:
+        k = base64url(oidc_client.client_secret.encode('utf-8'))
+        jwk = JWK(kty='oct', k=force_text(k))
+    elif oidc_client.idtoken_algo == OIDCClient.ALGO_RSA:
+        jwk = get_first_rsa_sig_key()
+    elif oidc_client.idtoken_algo == OIDCClient.ALGO_EC:
+        jwk = get_first_ec_sig_key()
+
+    profile_type = ProfileType.objects.create(
+        name='One Manager Type',
+        slug='one-manager-type',
+    )
+    profile = Profile.objects.create(
+        user=simple_user,
+        identifier='abc',
+        profile_type=profile_type,
+        email='profile@ad.dr.ess',
+    )
+
+    params = {
+        'client_id': oidc_client.client_id,
+        'client_secret': oidc_client.client_secret,
+        'grant_type': 'password',
+        'username': simple_user.username,
+        'password': simple_user.username,
+        # profile id yet client doesn't active profile management
+        'profile': profile.id,
+    }
+    response = app.post(token_url, params=params, status=400)
+
+    assert response.json['error'] == 'access_denied'
+    assert response.json['error_description'] == 'User profile requested yet client does not manage profiles.'
+
+    oidc_client.activate_user_profiles = True
+    oidc_client.save()
+
+    # wrong profile id
+    params['profile'] = profile.id + 1
+    response = app.post(token_url, params=params, status=400)
+
+    assert response.json['error'] == 'access_denied'
+    assert response.json['error_description'] == 'Invalid profile'
+
+    another_profile = Profile.objects.create(
+        user=admin,
+        identifier='def',
+        profile_type=profile_type,
+        email='admin@ad.dr.ess',
+    )
+
+    # another user's profile
+    params['profile'] = another_profile.id
+    response = app.post(token_url, params=params, status=400)
+
+    assert response.json['error'] == 'access_denied'
+    # the oidc provider doesn't reveal that this is a valid profile id, linked to another user:
+    assert response.json['error_description'] == 'Invalid profile'
+
+    # correct profile
+    params['profile'] = profile.id
+    response = app.post(token_url, params=params)
+    token = response.json['id_token']
+    jwt = JWT()
+    jwt.deserialize(token, key=jwk)
+    claims = json.loads(jwt.claims)
+
+    assert set(claims) == {
+        'acr',
+        'aud',
+        'auth_time',
+        'exp',
+        'iat',
+        'iss',
+        'sub',
+        # profile-related claims in the id token:
+        'profile_type',
+        'profile_identifier',
+        'email',
+        'email_verified',
+    }
+    assert claims['profile_type'] == 'one-manager-type'
+    assert claims['profile_identifier'] == 'abc'
+    assert claims['email'] == 'profile@ad.dr.ess'
+
+    access_token = response.json['access_token']
+    user_info_url = make_url('oidc-user-info')
+    response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
+
+    assert response.json['profile_type'] == 'one-manager-type'
+    assert response.json['profile_identifier'] == 'abc'
+    assert response.json['email'] == 'profile@ad.dr.ess'
+    assert response.json['sub'] == claims['sub']
-- 
2.37.2

