From 59d854c707554a1fed9c1118b732ccfc2bffb20d Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Tue, 12 Mar 2019 09:50:53 +0100
Subject: [PATCH] [WIP] oidc: support ec signature for rp and idp modules
 (#26253)

---
 src/authentic2_auth_oidc/backends.py |  9 ++++++
 src/authentic2_auth_oidc/models.py   |  4 ++-
 src/authentic2_auth_oidc/utils.py    |  3 ++
 src/authentic2_idp_oidc/models.py    |  2 ++
 src/authentic2_idp_oidc/utils.py     | 28 ++++++++++++-----
 src/authentic2_idp_oidc/views.py     |  2 +-
 tests/test_idp_oidc.py               | 45 ++++++++++++++++++++++++----
 7 files changed, 78 insertions(+), 15 deletions(-)

diff --git a/src/authentic2_auth_oidc/backends.py b/src/authentic2_auth_oidc/backends.py
index d03b959e..a1c99a76 100644
--- a/src/authentic2_auth_oidc/backends.py
+++ b/src/authentic2_auth_oidc/backends.py
@@ -52,6 +52,15 @@ class OIDCBackend(ModelBackend):
                                'no client_secret is defined on provider %s', id_token.iss)
                 return None
             algs = ['HS256', 'HS384', 'HS512']
+        elif provider.idtoken_algo == models.OIDCProvider.ALGO_EC:
+            key = provider.jwkset
+            if not key:
+                logger.warning('auth_oidc: idtoken signature algorithm is EC but '
+                               'no JWKSet is defined on provider %s', id_token.iss)
+                return None
+            if len(key['keys']) == 1:
+                key = list(key['keys'])[0]
+            algs = ['ES256', 'ES384', 'ES512']
         else:
             key = None
 
diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py
index 28ada5da..2951218a 100644
--- a/src/authentic2_auth_oidc/models.py
+++ b/src/authentic2_auth_oidc/models.py
@@ -37,10 +37,12 @@ class OIDCProvider(models.Model):
     ALGO_NONE = 0
     ALGO_RSA = 1
     ALGO_HMAC = 2
+    ALGO_EC = 3
     ALGO_CHOICES = [
         (ALGO_NONE, _('none')),
         (ALGO_RSA, _('RSA')),
         (ALGO_HMAC, _('HMAC')),
+        (ALGO_EC, _('EC')),
     ]
 
     name = models.CharField(
@@ -138,7 +140,7 @@ class OIDCProvider(models.Model):
     def jwkset(self):
         from authentic2.crypto import base64url_encode
 
-        if self.idtoken_algo == self.ALGO_RSA:
+        if self.idtoken_algo in (self.ALGO_RSA, self.ALGO_EC):
             if self.jwkset_json:
                 return JWKSet.from_json(json.dumps(self.jwkset_json))
         if self.idtoken_algo == self.ALGO_HMAC:
diff --git a/src/authentic2_auth_oidc/utils.py b/src/authentic2_auth_oidc/utils.py
index 1f2956e7..020ba123 100644
--- a/src/authentic2_auth_oidc/utils.py
+++ b/src/authentic2_auth_oidc/utils.py
@@ -255,6 +255,9 @@ def register_issuer(name, issuer=None, openid_configuration=None, verify=True, t
     elif (set(['HS256', 'HS384', 'HS512']) &
           set(openid_configuration['id_token_signing_alg_values_supported'])):
         idtoken_algo = models.OIDCProvider.HMAC
+    elif (set(['ES256', 'ES384', 'ES512']) &
+          set(openid_configuration['id_token_signing_alg_values_supported'])):
+        idtoken_algo = models.OIDCProvider.ALGO_ES
     else:
         raise ValueError(_('no common algorithm found for signing idtokens: %s') %
                          openid_configuration['id_token_signing_alg_values_supported'])
diff --git a/src/authentic2_idp_oidc/models.py b/src/authentic2_idp_oidc/models.py
index 58775e61..b5903186 100644
--- a/src/authentic2_idp_oidc/models.py
+++ b/src/authentic2_idp_oidc/models.py
@@ -54,9 +54,11 @@ class OIDCClient(Service):
 
     ALGO_RSA = 1
     ALGO_HMAC = 2
+    ALGO_EC = 3
     ALGO_CHOICES = [
         (ALGO_HMAC, _('HMAC')),
         (ALGO_RSA, _('RSA')),
+        (ALGO_EC, _('EC')),
     ]
     FLOW_AUTHORIZATION_CODE = 1
     FLOW_IMPLICIT = 2
diff --git a/src/authentic2_idp_oidc/utils.py b/src/authentic2_idp_oidc/utils.py
index 88e0dbf3..4430da05 100644
--- a/src/authentic2_idp_oidc/utils.py
+++ b/src/authentic2_idp_oidc/utils.py
@@ -35,16 +35,25 @@ def get_jwkset():
     return jwkset
 
 
-def get_first_rsa_sig_key():
-    for key in get_jwkset()['keys']:
-        if key._params['kty'] != 'RSA':
-            continue
-        use = key._params.get('use')
-        if use is None or use == 'sig':
-            return key
+def get_first_sig_key_by_type(kty=None):
+    if kty:
+        for key in get_jwkset()['keys']:
+            if key._params['kty'] != kty:
+                continue
+            use = key._params.get('use')
+            if use is None or use == 'sig':
+                return key
     return None
 
 
+def get_first_rsa_sig_key():
+    return get_first_sig_key_by_type('RSA')
+
+
+def get_first_ec_sig_key():
+    return get_first_sig_key_by_type('EC')
+
+
 def make_idtoken(client, claims):
     '''Make a serialized JWT targeted for this client'''
     if client.idtoken_algo == client.ALGO_HMAC:
@@ -56,6 +65,11 @@ def make_idtoken(client, claims):
         header['kid'] = jwk.key_id
         if jwk is None:
             raise ImproperlyConfigured('no RSA key for signature operation in A2_IDP_OIDC_JWKSET')
+    elif client.idtoken_algo == client.ALGO_EC:
+        header = {'alg': 'ES256'}
+        jwk = get_first_ec_sig_key()
+        if jwk is None:
+            raise ImproperlyConfigured('no EC key for signature operation in A2_IDP_OIDC_JWKSET')
     else:
         raise NotImplementedError
     jwt = JWT(header=header, claims=claims)
diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py
index 137282cb..3fbdd35a 100644
--- a/src/authentic2_idp_oidc/views.py
+++ b/src/authentic2_idp_oidc/views.py
@@ -37,7 +37,7 @@ def openid_configuration(request, *args, **kwargs):
             'clien_secret_post', 'client_secret_basic',
         ],
         'id_token_signing_alg_values_supported': [
-            'RS256', 'HS256',
+            'RS256', 'HS256', 'ES256',
         ],
         'userinfo_endpoint': request.build_absolute_uri(reverse('oidc-user-info')),
         'frontchannel_logout_supported': True,
diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py
index 514fea9d..edb70417 100644
--- a/tests/test_idp_oidc.py
+++ b/tests/test_idp_oidc.py
@@ -42,6 +42,15 @@ JWKSET = {
             "n": "0lN6CiJGFD8BSPV_azLoEl6Nq-WlHkU743D5rqvzw1sOaxstMGxAhVk2YIhWwfvapV6XjO_yvc4778VBTELOdjRw6BGUdBJepdwkL__TPyjEVhqMQj9MKhEU4GUy9w0Lsilb5D01kfrOKpmdcYw4jhcDvb0H4-LZgh1Vk84vF4WaQCUg_AX4drVDQOjoU8kuWIM8gz9w6zEsbIw-gtMRpFwS8ncA0zDX5VfyC77iMxzFftDIP2gM5GvdevMzvP9IRkRRBhP9vV4JchBFPHSA9OPJcnySjJJNW6aAJn6P6JasN1z68khjufM09J8UzmLAZYOq7gUG95Ox1KsV-g337Q",
             "e": "AQAB",
             "p": "-Nyj_Sw3f2HUqSssCZv84y7b3blOtGGAhfYN_JtGfcTQv2bOtxrIUzeonCi-Z_1W4hO10tqxJcOB0ibtDqkDlLhnLaIYOBfriITRFK83EJG5sC-0KTmFzUXFTA2aMc1QgP-Fu6gUfQpPqLgWxhx8EFhkBlBZshKU5-C-385Sco0"
+        },
+        {
+            "kty": "EC",
+            "d": "wwULaR9UYWZW6U2oEbkz3sO1lhPSj6DyA6e7PiUfhog",
+            "use": "sig",
+            "crv": "P-256",
+            "x": "HZMHZkX-63heqA5pvWn-UR7bgcXZNEcQa5wfvG_BzTw",
+            "y": "SUCuwjjiyKvGq5Odr0sjDqjha_CBqks0JQFrR7Ei5OQ",
+            "alg": "ES256"
         }
     ]
 }
@@ -223,13 +232,25 @@ def test_authorization_code_sso(login_first, oidc_settings, oidc_client, simple_
         access_token = query['access_token'][0]
         id_token = query['id_token'][0]
 
-    if oidc_client.idtoken_algo == oidc_client.ALGO_RSA:
-        key = JWKSet.from_json(app.get(reverse('oidc-certs')).content)
+    if oidc_client.idtoken_algo in (oidc_client.ALGO_RSA, oidc_client.ALGO_EC):
+        keyset = JWKSet.from_json(app.get(reverse('oidc-certs')).content)
+        for k in keyset.get('keys'):
+            if {
+                'RSA': oidc_client.ALGO_RSA,
+                'EC': oidc_client.ALGO_EC
+            }.get(k.key_type) == oidc_client.idtoken_algo:
+                algs=[{
+                    oidc_client.ALGO_RSA: 'RS256',
+                    oidc_client.ALGO_EC: 'ES256'
+                }.get(oidc_client.idtoken_algo)]
+                key = k
+                break
     elif oidc_client.idtoken_algo == oidc_client.ALGO_HMAC:
         key = JWK(kty='oct', k=base64.b64encode(oidc_client.client_secret.encode('utf-8')))
+        algs = ['HS256']
     else:
         raise NotImplementedError
-    jwt = JWT(jwt=id_token, key=key)
+    jwt = JWT(jwt=id_token, key=key, algs=algs)
     claims = json.loads(jwt.claims)
     assert set(claims) >= set(['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'auth_time', 'acr'])
     assert claims['nonce'] == 'yyy'
@@ -826,13 +847,25 @@ def test_role_control_access(login_first, oidc_settings, oidc_client, simple_use
         query = urlparse.parse_qs(location.fragment)
         id_token = query['id_token'][0]
 
-    if oidc_client.idtoken_algo == oidc_client.ALGO_RSA:
-        key = JWKSet.from_json(app.get(reverse('oidc-certs')).content)
+    if oidc_client.idtoken_algo in (oidc_client.ALGO_RSA, oidc_client.ALGO_EC):
+        keyset = JWKSet.from_json(app.get(reverse('oidc-certs')).content)
+        for k in keyset.get('keys'):
+            if {
+                'RSA': oidc_client.ALGO_RSA,
+                'EC': oidc_client.ALGO_EC
+            }.get(k.key_type) == oidc_client.idtoken_algo:
+                algs=[{
+                    oidc_client.ALGO_RSA: 'RS256',
+                    oidc_client.ALGO_EC: 'ES256'
+                }.get(oidc_client.idtoken_algo)]
+                key = k
+                break
     elif oidc_client.idtoken_algo == oidc_client.ALGO_HMAC:
         key = JWK(kty='oct', k=base64.b64encode(oidc_client.client_secret.encode('utf-8')))
+        algs = ['HS256']
     else:
         raise NotImplementedError
-    jwt = JWT(jwt=id_token, key=key)
+    jwt = JWT(jwt=id_token, key=key, algs=algs)
     claims = json.loads(jwt.claims)
     if login_first:
         assert claims['acr'] == '0'
-- 
2.20.1

