Projet

Général

Profil

0001-auth_oidc-support-signed-authz-requests-through-jwt-.patch

Paul Marillonnet (retour le 15/04), 22 juillet 2020 15:20

Télécharger (17,5 ko)

Voir les différences:

Subject: [PATCH] auth_oidc: support signed authz requests through jwt bearer
 grants (#36966)

 .../migrations/0008_auto_20200722_1452.py     |  27 +++
 src/authentic2_auth_oidc/models.py            |  16 ++
 src/authentic2_auth_oidc/views.py             |  71 ++++++-
 tests/test_auth_oidc.py                       | 196 ++++++++++++------
 4 files changed, 246 insertions(+), 64 deletions(-)
 create mode 100644 src/authentic2_auth_oidc/migrations/0008_auto_20200722_1452.py
src/authentic2_auth_oidc/migrations/0008_auto_20200722_1452.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-07-22 12:52
3
from __future__ import unicode_literals
4

  
5
import authentic2_auth_oidc.models
6
import django.contrib.postgres.fields.jsonb
7
from django.db import migrations, models
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    dependencies = [
13
        ('authentic2_auth_oidc', '0007_auto_20200317_1732'),
14
    ]
15

  
16
    operations = [
17
        migrations.AddField(
18
            model_name='oidcprovider',
19
            name='request_signature_jwkset_json',
20
            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[authentic2_auth_oidc.models.validate_jwkset], verbose_name='JSON WebKeyset used for outgoing signatures'),
21
        ),
22
        migrations.AddField(
23
            model_name='oidcprovider',
24
            name='request_signature_supported',
25
            field=models.BooleanField(default=False, verbose_name='Request signature supported'),
26
        ),
27
    ]
src/authentic2_auth_oidc/models.py
121 121
    claims_parameter_supported = models.BooleanField(
122 122
        verbose_name=_('Claims parameter supported'),
123 123
        default=False)
124
    request_signature_supported = models.BooleanField(
125
        verbose_name=_('Request signature supported'),
126
        default=False)
127
    request_signature_jwkset_json = JSONField(
128
        verbose_name=_('JSON WebKeyset used for outgoing signatures'),
129
        null=True,
130
        blank=True,
131
        validators=[validate_jwkset])
124 132

  
125 133
    # ou where new users should be created
126 134
    strategy = models.CharField(
......
160 168
            return JWKSet.from_json(json.dumps(self.jwkset_json))
161 169
        return None
162 170

  
171
    @property
172
    def request_signature_jwkset(self):
173
        if self.request_signature_jwkset_json:
174
            return JWKSet.from_json(json.dumps(
175
                    self.request_signature_jwkset_json))
176
        return None
177

  
178

  
163 179
    def __str__(self):
164 180
        return self.name
165 181

  
src/authentic2_auth_oidc/views.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import datetime
17 18
import uuid
18 19
import logging
19 20
import json
20 21

  
21 22
import requests
22 23

  
24
from jwcrypto.common import JWException
25
from jwcrypto.jwt import JWT
26

  
23 27
from django.urls import reverse
24 28
from django.utils.translation import get_language, ugettext as _
25 29
from django.contrib import messages
......
142 146
                messages.warning(request, _('Login with OpenIDConnect failed, report %s to an '
143 147
                                            'administrator') % request.request_id)
144 148
            return self.continue_to_next_url()
145
        if not code:
146
            messages.warning(request, _('Missing code, report %s to an administrator') %
147
                             request.request_id)
148
            logger.warning('auth_oidc: missing code, %r', request.GET)
149
            return self.continue_to_next_url()
150
        try:
149
        if not provider.request_signature_supported:
150
            if not code:
151
                messages.warning(request, _('Missing code, report %s to an administrator') %
152
                                 request.request_id)
153
                logger.warning('auth_oidc: missing code, %r', request.GET)
154
                return self.continue_to_next_url()
151 155
            token_endpoint_request = {
152 156
                'grant_type': 'authorization_code',
153 157
                'code': code,
154 158
                'redirect_uri': request.build_absolute_uri(request.path),
155 159
            }
160
        elif code:
161
            logger.warning('authz code provided but grant type is signed '
162
                    'JWT. authz code \'%s\' will be ignored' % code)
163
            messages.warning(request, _('Provider %s configured for signed JWT grant but an '
164
                    'authorization code was provided') % provider.issuer)
165
            return self.continue_to_next_url()
166

  
167
        else:
168
            # JWT Bearer authz through OAuth assertion framework - see RFC 7523
169
            sign_key = None
170
            for key in provider.request_signature_jwkset:
171
                if key.key_type in ['EC', 'RSA', 'HMAC']:
172
                    sign_key = key
173
                    break
174

  
175
            if not sign_key:
176
                messages.warning(request, _('Provider %s configured for signed JWT grant but no '
177
                        'signature key could be retrieved.') % provider.issuer)
178
                logger.warning(
179
                        'auth_oidc: provider %s has no jwt grant signature key' % provider.issuer)
180
                return self.continue_to_next_url()
181

  
182
            header = {
183
                # FIXME do not hard-code key length
184
                'alg': {'EC': 'ES256', 'RSA': 'RS256', 'HMAC': 'HS256'}.get(sign_key.key_type),
185
                'typ': 'authz JWT',
186
                'cty': 'JWT',
187
                'kid': sign_key.key_id,
188
            }
189
            now = datetime.datetime.now()
190
            exp = now + datetime.timedelta(hours=1)
191
            claims = {
192
                'iss': 'client %s' % provider.client_id,
193
                'sub': '', # resource owner is not know yet
194
                'aud': 'provider %s' % provider.issuer,
195
                'iat': int(now.timestamp()),
196
                'exp': int(exp.timestamp()),
197
            }
198
            jwt = JWT(header=header, claims=claims)
199
            try:
200
                jwt.make_signed_token(key=sign_key)
201
                jwt = jwt.serialize()
202
            except JWException as e:
203
                logger.error('error during jwt grant serialization: %s' % e)
204
                messages.warning(
205
                        request,
206
                        _('Error during grant request issuance, report %s to an administrator') %
207
                        request.request_id)
208
                return self.continue_to_next_url()
209
            token_endpoint_request = {
210
                'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
211
                'assertion': jwt,
212
                'scope': provider.scopes,
213
            }
214
        try:
156 215
            logger.debug('auth_oidc: sent request to token endpoint %r', token_endpoint_request)
157 216
            response = requests.post(provider.token_endpoint, data=token_endpoint_request,
158 217
                                     auth=(provider.client_id, provider.client_secret), timeout=10)
tests/test_auth_oidc.py
117 117
    jwkset.add(key_ec)
118 118
    return jwkset
119 119

  
120

  
121
@pytest.fixture
122
def request_signature_jwkset_json():
123
    key_rsa = JWK.generate(kty='RSA', size=512, kid=KID_RSA)
124
    key_ec = JWK.generate(kty='EC', size=256, kid=KID_EC)
125
    jwkset = JWKSet()
126
    jwkset.add(key_rsa)
127
    jwkset.add(key_ec)
128
    return json.loads(jwkset.export(private_keys=True))
129

  
130

  
120 131
OIDC_PROVIDER_PARAMS = [
121 132
    {},
122 133
    {
......
246 257

  
247 258
    @urlmatch(netloc=token_endpoint.netloc, path=token_endpoint.path)
248 259
    def token_endpoint_mock(url, request):
249
        if urlparse.parse_qs(request.body).get('code') == [code]:
250
            exp = now() + datetime.timedelta(seconds=10)
251
            id_token = {
252
                'iss': oidc_provider.issuer,
253
                'sub': sub,
254
                'iat': int(now().timestamp()),
255
                'aud': str(oidc_provider.client_id),
256
                'exp': int(exp.timestamp()),
257
                'name': 'doe',
258
            }
259
            if nonce:
260
                id_token['nonce'] = nonce
261
            if extra_id_token:
262
                id_token.update(extra_id_token)
263

  
264
            if oidc_provider.idtoken_algo in (OIDCProvider.ALGO_RSA,
265
                    OIDCProvider.ALGO_EC):
266
                alg = {
267
                    OIDCProvider.ALGO_RSA: 'RS256',
268
                    OIDCProvider.ALGO_EC: 'ES256',
269
                }.get(oidc_provider.idtoken_algo)
270
                jwk = None
271
                for key in oidc_provider_jwkset['keys']:
272
                    if key.key_type == {
273
                                OIDCProvider.ALGO_RSA: 'RSA',
274
                                OIDCProvider.ALGO_EC: 'EC',
275
                            }.get(oidc_provider.idtoken_algo):
276
                        jwk = key
277
                        break
278
                if provides_kid_header:
279
                    header = {'alg': alg, 'kid': kid}
280
                else:
281
                    header = {'alg': alg, 'kid': jwk.key_id}
282
                jwt = JWT(header=header, claims=id_token)
283
                jwt.make_signed_token(jwk)
284
            else: # hmac
285
                jwt = JWT(header={'alg': 'HS256'},
286
                          claims=id_token)
287
                k = base64url_encode(oidc_provider.client_secret.encode('utf-8'))
288
                jwt.make_signed_token(
289
                    JWK(kty='oct',
290
                        k=force_text(k)))
291

  
292
            content = {
293
                'access_token': '1234',
294
                # check token_type is case insensitive
295
                'token_type': random.choice(['B', 'b']) + 'earer',
296
                'id_token': jwt.serialize(),
297
            }
298
            return {
299
                'content': json.dumps(content),
300
                'headers': {
301
                    'content-type': 'application/json',
302
                },
303
                'status_code': 200,
304
            }
305
        else:
260
        if oidc_provider.request_signature_supported:
261
            parsed = urlparse.parse_qs(request.body)
262
            assert len(parsed.get('assertion')) == 1
263
            assert parsed.get('grant_type') == ['urn:ietf:params:oauth:grant-type:jwt-bearer']
264
            assertion = parsed.get('assertion')[0]
265
            assert len(assertion.split('.')) == 3 # header, payload, signature
266

  
267
            # JWT deserialization:
268
            jwt = JWT()
269
            jwt.deserialize(jwt=assertion, key=oidc_provider.request_signature_jwkset)
270
            # todo check no claim are missing claim
271
        elif urlparse.parse_qs(request.body).get('code') != [code]:
306 272
            return {
307 273
                'content': json.dumps({'error': 'invalid request'}),
308 274
                'headers': {
......
310 276
                },
311 277
                'status_code': 400,
312 278
            }
279
        exp = now() + datetime.timedelta(seconds=10)
280
        id_token = {
281
            'iss': oidc_provider.issuer,
282
            'sub': sub,
283
            'iat': int(now().timestamp()),
284
            'aud': str(oidc_provider.client_id),
285
            'exp': int(exp.timestamp()),
286
            'name': 'doe',
287
        }
288
        if nonce:
289
            id_token['nonce'] = nonce
290
        if extra_id_token:
291
            id_token.update(extra_id_token)
292

  
293
        if oidc_provider.idtoken_algo in (OIDCProvider.ALGO_RSA,
294
                OIDCProvider.ALGO_EC):
295
            alg = {
296
                OIDCProvider.ALGO_RSA: 'RS256',
297
                OIDCProvider.ALGO_EC: 'ES256',
298
            }.get(oidc_provider.idtoken_algo)
299
            jwk = None
300
            for key in oidc_provider_jwkset['keys']:
301
                if key.key_type == {
302
                            OIDCProvider.ALGO_RSA: 'RSA',
303
                            OIDCProvider.ALGO_EC: 'EC',
304
                        }.get(oidc_provider.idtoken_algo):
305
                    jwk = key
306
                    break
307
            if provides_kid_header:
308
                header = {'alg': alg, 'kid': kid}
309
            else:
310
                header = {'alg': alg, 'kid': jwk.key_id}
311
            jwt = JWT(header=header, claims=id_token)
312
            jwt.make_signed_token(jwk)
313
        else: # hmac
314
            jwt = JWT(header={'alg': 'HS256'},
315
                      claims=id_token)
316
            k = base64url_encode(oidc_provider.client_secret.encode('utf-8'))
317
            jwt.make_signed_token(
318
                JWK(kty='oct',
319
                    k=force_text(k)))
320

  
321
        content = {
322
            'access_token': '1234',
323
            # check token_type is case insensitive
324
            'token_type': random.choice(['B', 'b']) + 'earer',
325
            'id_token': jwt.serialize(),
326
        }
327
        return {
328
            'content': json.dumps(content),
329
            'headers': {
330
                'content-type': 'application/json',
331
            },
332
            'status_code': 200,
333
        }
313 334

  
314 335
    @urlmatch(netloc=userinfo_endpoint.netloc, path=userinfo_endpoint.path)
315 336
    def user_info_endpoint_mock(url, request):
......
482 503
    assert response['Location'] == '/accounts/oidc/login/%s/' % oidc_provider.pk
483 504

  
484 505

  
485

  
486 506
def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, hooks):
487 507
    OU = get_ou_model()
488 508
    cassis = OU.objects.create(name='Cassis', slug='cassis')
......
598 618
    assert response.location.startswith('https://server.example.com/logout?')
599 619

  
600 620

  
621
def test_jwt_bearer_authz_grant(app, caplog, code, oidc_provider, oidc_provider_jwkset, request_signature_jwkset_json, hooks):
622
    OU = get_ou_model()
623
    oidc_provider.request_signature_supported = True
624
    oidc_provider.request_signature_jwkset_json = request_signature_jwkset_json
625
    oidc_provider.save()
626
    cassis = OU.objects.create(name='Cassis', slug='cassis')
627

  
628
    response = app.get('/admin/').maybe_follow()
629
    assert oidc_provider.name in response.text
630
    response = response.click(oidc_provider.name)
631
    location = urlparse.urlparse(response.location)
632
    endpoint = urlparse.urlparse(oidc_provider.authorization_endpoint)
633
    assert location.scheme == endpoint.scheme
634
    assert location.netloc == endpoint.netloc
635
    assert location.path == endpoint.path
636
    User = get_user_model()
637
    assert User.objects.count() == 0
638

  
639
    query = check_simple_qs(urlparse.parse_qs(location.query))
640
    assert query['state'] in app.session['auth_oidc']
641
    assert query['response_type'] == 'code'
642
    assert query['client_id'] == str(oidc_provider.client_id)
643
    assert query['scope'] == 'openid'
644
    assert query['redirect_uri'] == 'http://testserver' + reverse('oidc-login-callback')
645

  
646
    nonce = app.session['auth_oidc'][query['state']]['request']['nonce']
647

  
648
    if oidc_provider.claims_parameter_supported:
649
        claims = json.loads(query['claims'])
650
        assert claims['id_token']['sub'] is None
651
        assert claims['userinfo']['email']['essential']
652
        assert claims['userinfo']['given_name']['essential']
653
        assert claims['userinfo']['family_name']['essential']
654
        assert claims['userinfo']['ou'] is None
655

  
656
    with utils.check_log(caplog, 'authz code provided but grant type is signed JWT'):
657
        with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
658
            response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': query['state']})
659
    assert len(hooks.auth_oidc_backend_modify_user) == 0
660

  
661
    with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, '', nonce=nonce):
662
        response = app.get(login_callback_url(oidc_provider), {'state': query['state']})
663
    assert len(hooks.auth_oidc_backend_modify_user) == 1
664
    assert set(hooks.auth_oidc_backend_modify_user[0]['kwargs']) >= set(
665
        ['user', 'provider', 'user_info', 'id_token', 'access_token'])
666
    assert urlparse.urlparse(response['Location']).path == '/admin/'
667
    assert User.objects.count() == 1
668
    user = User.objects.get()
669
    assert user.ou == get_default_ou()
670
    assert user.username == 'john.doe'
671
    assert user.first_name == 'John'
672
    assert user.last_name == 'Doe'
673
    assert user.email == 'john.doe@example.com'
674
    assert user.attributes.first_name == 'John'
675
    assert user.attributes.last_name == 'Doe'
676
    assert AttributeValue.objects.filter(content='John', verified=True).count() == 1
677
    assert AttributeValue.objects.filter(content='Doe', verified=False).count() == 1
678
    assert last_authentication_event(session=app.session)['nonce'] == nonce
679

  
680

  
601 681
def test_show_on_login_page(app, oidc_provider):
602 682
    response = app.get('/login/')
603 683
    assert 'oidc-a-oididp' in response.text
604
-