Projet

Général

Profil

0001-idp_oidc-support-oauth2-resource-owner-password-cred.patch

Paul Marillonnet, 24 janvier 2020 11:24

Télécharger (31,8 ko)

Voir les différences:

Subject: [PATCH] idp_oidc: support oauth2 resource owner password credential
 grant (#35205)

 setup.py                                      |   1 +
 src/authentic2_idp_oidc/app_settings.py       |   8 +
 .../migrations/0001_initial.py                |   4 +-
 .../migrations/0012_auto_20200122_2258.py     |  25 +++
 src/authentic2_idp_oidc/models.py             |  18 +-
 src/authentic2_idp_oidc/utils.py              |   5 +-
 src/authentic2_idp_oidc/views.py              | 212 +++++++++++++++---
 tests/test_idp_oidc.py                        | 207 ++++++++++++++++-
 8 files changed, 438 insertions(+), 42 deletions(-)
 create mode 100644 src/authentic2_idp_oidc/migrations/0012_auto_20200122_2258.py
setup.py
122 122
          'dnspython>=1.10',
123 123
          'Django-Select2>5,<6',
124 124
          'django-tables2>=1.0,<2.0',
125
          'django-ratelimit',
125 126
          'gadjo>=0.53',
126 127
          'django-import-export>=0.2.7,<=0.4.5',
127 128
          'djangorestframework>=3.3,<3.5',
src/authentic2_idp_oidc/app_settings.py
53 53
    def IDTOKEN_DURATION(self):
54 54
        return self._setting('IDTOKEN_DURATION', 30)
55 55

  
56
    @property
57
    def ACCESS_TOKEN_DURATION(self):
58
        return self._setting('ACCESS_TOKEN_DURATION', 3600 * 8)
59

  
60
    @property
61
    def PASSWORD_GRANT_RATELIMIT(self):
62
        return self._setting('PASSWORD_GRANT_RATELIMIT', '100/m')
63

  
56 64
app_settings = AppSettings('A2_IDP_OIDC_')
57 65
app_settings.__name__ = __name__
58 66
sys.modules[__name__] = app_settings
src/authentic2_idp_oidc/migrations/0001_initial.py
20 20
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
21 21
                ('uuid', models.CharField(default=authentic2_idp_oidc.models.generate_uuid, max_length=128, verbose_name='uuid')),
22 22
                ('scopes', models.TextField(verbose_name='scopes')),
23
                ('session_key', models.CharField(max_length=128, verbose_name='session key')),
23
                ('session_key', models.CharField(blank=True, max_length=128, verbose_name='session key')),
24 24
                ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
25 25
                ('expired', models.DateTimeField(verbose_name='expire')),
26 26
            ],
......
40 40
                ('service_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='authentic2.Service')),
41 41
                ('client_id', models.CharField(default=authentic2_idp_oidc.models.generate_uuid, unique=True, max_length=255, verbose_name='client id')),
42 42
                ('client_secret', models.CharField(default=authentic2_idp_oidc.models.generate_uuid, max_length=255, verbose_name='client secret')),
43
                ('authorization_flow', models.PositiveIntegerField(default=1, verbose_name='authorization flow', choices=[(1, 'authorization code'), (2, 'implicit/native')])),
43
                ('authorization_flow', models.PositiveIntegerField(choices=[(1, 'authorization code'), (2, 'implicit/native'), (3, 'resource owner password credentials')], default=1, verbose_name='authorization flow')),
44 44
                ('redirect_uris', models.TextField(verbose_name='redirect URIs', validators=[authentic2_idp_oidc.models.validate_https_url])),
45 45
                ('sector_identifier_uri', models.URLField(verbose_name='sector identifier URI', blank=True)),
46 46
                ('identifier_policy', models.PositiveIntegerField(default=2, verbose_name='identifier policy', choices=[(1, 'uuid'), (2, 'pairwise'), (3, 'email')])),
src/authentic2_idp_oidc/migrations/0012_auto_20200122_2258.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.20 on 2020-01-22 21:58
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    dependencies = [
11
        ('authentic2_idp_oidc', '0011_auto_20180808_1546'),
12
    ]
13

  
14
    operations = [
15
        migrations.AddField(
16
            model_name='oidcclient',
17
            name='access_token_duration',
18
            field=models.DurationField(blank=True, default=None, null=True, verbose_name='time during which the access token is valid'),
19
        ),
20
        migrations.AddField(
21
            model_name='oidcclient',
22
            name='scope',
23
            field=models.TextField(blank=True, default=b'', verbose_name='resource owner credentials grant scope'),
24
        ),
25
    ]
src/authentic2_idp_oidc/models.py
78 78
    ]
79 79
    FLOW_AUTHORIZATION_CODE = 1
80 80
    FLOW_IMPLICIT = 2
81
    FLOW_RESOURCE_OWNER_CRED = 3
81 82
    FLOW_CHOICES = [
82 83
        (FLOW_AUTHORIZATION_CODE, _('authorization code')),
83 84
        (FLOW_IMPLICIT, _('implicit/native')),
85
        (FLOW_RESOURCE_OWNER_CRED, _('resource owner password credentials')),
84 86
    ]
85 87

  
86 88
    AUTHORIZATION_MODE_BY_SERVICE = 1
......
106 108
        blank=True,
107 109
        null=True,
108 110
        default=None)
111
    access_token_duration = models.DurationField(
112
        verbose_name=_('time during which the access token is valid'),
113
        blank=True,
114
        null=True,
115
        default=None)
109 116
    authorization_mode = models.PositiveIntegerField(
110 117
        default=AUTHORIZATION_MODE_BY_SERVICE,
111 118
        choices=AUTHORIZATION_MODES,
......
129 136
        verbose_name=_('identifier policy'),
130 137
        default=POLICY_PAIRWISE,
131 138
        choices=IDENTIFIER_POLICIES)
139
    scope = models.TextField(
140
        verbose_name=_('resource owner credentials grant scope'),
141
        help_text=_('Permitted or default scopes (for credentials grant)'),
142
        default='',
143
        blank=True)
132 144

  
133 145
    @to_iter
134 146
    def get_idtoken_algorithms():
......
198 210
            return True
199 211
        return False
200 212

  
213
    def scope_set(self):
214
        return utils.scope_set(self.scope)
215

  
201 216
    def __repr__(self):
202 217
        return ('<OIDCClient name:%r client_id:%r identifier_policy:%r>' %
203 218
                (self.name, self.client_id, self.get_identifier_policy_display()))
......
312 327
        verbose_name=_('scopes'))
313 328
    session_key = models.CharField(
314 329
        verbose_name=_('session key'),
315
        max_length=128)
330
        max_length=128,
331
        blank=True)
316 332

  
317 333
    # metadata
318 334
    created = models.DateTimeField(
src/authentic2_idp_oidc/utils.py
179 179

  
180 180

  
181 181
def create_user_info(request, client, user, scope_set, id_token=False):
182
    '''Create user info dictionnary'''
182
    '''Create user info dictionary'''
183 183
    user_info = {
184
        'sub': make_sub(client, user)
185 184
    }
185
    if 'openid' in scope_set:
186
        user_info['sub'] = make_sub(client, user)
186 187
    attributes = get_attributes({
187 188
        'user': user,
188 189
        'request': request,
src/authentic2_idp_oidc/views.py
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import logging
18
import math
18 19
import datetime
19 20
import json
20 21
import base64
21 22
import time
22 23

  
23
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
24
from django.http import (HttpResponse, HttpResponseBadRequest,
25
                         HttpResponseNotAllowed, JsonResponse)
24 26
from django.utils import six
25 27
from django.utils.timezone import now, utc
26 28
from django.utils.http import urlencode
......
28 30
from django.views.decorators.csrf import csrf_exempt
29 31
from django.core.urlresolvers import reverse
30 32
from django.contrib import messages
33
from django.contrib.auth import authenticate
31 34
from django.conf import settings
32 35
from django.utils.translation import ugettext as _
36
from ratelimit.utils import is_ratelimited
33 37

  
38
from authentic2 import app_settings as a2_app_settings
34 39
from authentic2.decorators import setting_enabled
40
from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
35 41
from authentic2.utils import (login_require, redirect, timestamp_from_datetime,
36 42
                              last_authentication_event, make_url)
37 43
from authentic2.views import logout as a2_logout
......
60 66
        'frontchannel_logout_supported': True,
61 67
        'frontchannel_logout_session_supported': True,
62 68
    }
63
    return HttpResponse(json.dumps(metadata), content_type='application/json')
69
    return JsonResponse(metadata)
64 70

  
65 71

  
66 72
@setting_enabled('ENABLE', settings=app_settings)
......
90 96

  
91 97

  
92 98
def idtoken_duration(client):
93
    if client.idtoken_duration:
94
        return client.idtoken_duration
95
    return datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION)
99
    return client.idtoken_duration or datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION)
100

  
101

  
102
def access_token_duration(client):
103
    return client.access_token_duration or datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION)
104

  
105

  
106
def allowed_scopes(client):
107
    return client.scope_set() or app_settings.SCOPES or ['openid', 'email', 'profile']
108

  
109

  
110
def is_scopes_allowed(scopes, client):
111
    return scopes <= set(allowed_scopes(client))
96 112

  
97 113

  
98 114
@setting_enabled('ENABLE', settings=app_settings)
......
115 131
                       redirect_uri, client_id)
116 132
        return redirect(request, 'auth_homepage')
117 133

  
134
    if client.authorization_flow == client.FLOW_RESOURCE_OWNER_CRED:
135
        messages.warning(request, _('Client is configured for resource owner password crendetial grant type'))
136
        return authorization_error(request, 'auth_homepage',
137
                                   'unauthorized_client',
138
                                   error_description='authz endpoint is configured '
139
                                   'for resource owner password credential grant type')
140

  
118 141
    if not client.is_valid_redirect_uri(redirect_uri):
119 142
        messages.warning(request, _('Authorization request is invalid'))
120 143
        logger.warning(u'idp_oidc: authorization request error, unknown redirect_uri redirect_uri=%r client_id=%r',
......
171 194
                                   error_description='openid scope is missing',
172 195
                                   state=state,
173 196
                                   fragment=fragment)
174
    allowed_scopes = app_settings.SCOPES or ['openid', 'email', 'profile']
175
    if not (scopes <= set(allowed_scopes)):
197

  
198
    if not is_scopes_allowed(scopes, client):
176 199
        message = 'only "%s" scope(s) are supported, but "%s" requested' % (
177
            ', '.join(allowed_scopes), ', '.join(scopes))
200
            ', '.join(allowed_scopes(client)), ', '.join(scopes))
178 201
        return authorization_error(request, redirect_uri, 'invalid_scope',
179 202
                                   error_description=message,
180 203
                                   state=state,
......
290 313
    else:
291 314
        # FIXME: we should probably factorize this part with the token endpoint similar code
292 315
        need_access_token = 'token' in response_type.split()
293
        expires_in = 3600 * 8
316
        expires_in = access_token_duration(client)
294 317
        if need_access_token:
295 318
            access_token = models.OIDCAccessToken.objects.create(
296 319
                client=client,
297 320
                user=request.user,
298 321
                scopes=u' '.join(scopes),
299 322
                session_key=request.session.session_key,
300
                expired=start + datetime.timedelta(seconds=expires_in))
323
                expired=start + expires_in)
301 324
        acr = '0'
302 325
        if nonce is not None and last_auth.get('nonce') == nonce:
303 326
            acr = '1'
......
326 349
            params.update({
327 350
                'access_token': access_token.uuid,
328 351
                'token_type': 'Bearer',
329
                'expires_in': expires_in,
352
                'expires_in': expires_in.total_seconds(),
330 353
            })
331 354
        # query is transfered through the hashtag
332 355
        response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False)
......
365 388
    return client
366 389

  
367 390

  
368
def invalid_request(desc=None):
391
def error_response(error, error_description=None, status=400):
369 392
    content = {
370
        'error': 'invalid_request',
393
        'error': error,
371 394
    }
372
    if desc:
373
        content['desc'] = desc
374
    return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
395
    if error_description:
396
        content['error_description'] = error_description
397
    return JsonResponse(content, status=status)
375 398

  
376 399

  
377
@setting_enabled('ENABLE', settings=app_settings)
378
@csrf_exempt
379
def token(request, *args, **kwargs):
380
    if request.method != 'POST':
381
        return HttpResponseNotAllowed(['POST'])
382
    grant_type = request.POST.get('grant_type')
383
    if grant_type != 'authorization_code':
384
        return invalid_request('grant_type is not authorization_code')
400
def invalid_request_response(error_description=None):
401
    return error_response('invalid_request', error_description=error_description)
402

  
403

  
404
def access_denied_response(error_description=None):
405
    return error_response('access_denied', error_description=error_description)
406

  
407

  
408
def unauthorized_client_response(error_description=None):
409
    return error_response('unauthorized_client', error_description=error_description)
410

  
411

  
412
def invalid_client_response(error_description=None):
413
    return error_response('invalid_client', error_description=error_description)
414

  
415

  
416
def credential_grant_ratelimit_key(group, request):
417
    client = authenticate_client(request, client=None)
418
    if client:
419
        return client.client_id
420
    # return remote address when no valid client credentials have been provided
421
    return request.META['REMOTE_ADDR']
422

  
423

  
424
def idtoken_from_user_credential(request):
425
    if request.META.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
426
        return invalid_request_response(
427
            'wrong content type. request content type must be \'application/x-www-form-urlencoded\'')
428
    username = request.POST.get('username')
429
    scope = request.POST.get('scope')
430

  
431
    # scope is ignored, we used the configured scope
432

  
433
    if not all((username, request.POST.get('password'))):
434
        return invalid_request_response(
435
            'request must bear both username and password as '
436
            'parameters using the "application/x-www-form-urlencoded" '
437
            'media type')
438

  
439
    if is_ratelimited(
440
            request, group='ro-cred-grant', increment=True,
441
            key=credential_grant_ratelimit_key,
442
            rate=app_settings.PASSWORD_GRANT_RATELIMIT):
443
        return invalid_request_response(
444
            'reached rate limitation, too many erroneous requests')
445

  
446
    client = authenticate_client(request, client=None)
447

  
448
    if not client:
449
        return invalid_client_response('client authentication failed')
450

  
451
    if client.authorization_flow != models.OIDCClient.FLOW_RESOURCE_OWNER_CRED:
452
        return unauthorized_client_response(
453
            'client is not configured for resource owner password '
454
            'credential grant')
455

  
456
    exponential_backoff = ExponentialRetryTimeout(
457
        key_prefix='idp-oidc-ro-cred-grant',
458
        duration=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION,
459
        factor=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR)
460
    backoff_keys = (username, client.client_id)
461

  
462
    seconds_to_wait = exponential_backoff.seconds_to_wait(*backoff_keys)
463
    if seconds_to_wait > a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION:
464
        seconds_to_wait = a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION
465
    if seconds_to_wait:
466
        return invalid_request_response(
467
            'too many attempts with erroneous RO password, you must wait '
468
            '%s seconds to try again.' % int(math.ceil(seconds_to_wait)))
469

  
470
    user = authenticate(request, username=username, password=request.POST.get('password'))
471
    if not user:
472
        exponential_backoff.failure(*backoff_keys)
473
        return access_denied_response('invalid resource owner credentials')
474

  
475
    # limit requested scopes
476
    if scope is not None:
477
        scopes = utils.scope_set(scope) & client.scope_set()
478
    else:
479
        scopes = client.scope_set()
480

  
481
    exponential_backoff.success(*backoff_keys)
482
    start = now()
483
    # make access_token
484
    expires_in = access_token_duration(client)
485
    access_token = models.OIDCAccessToken.objects.create(
486
        client=client,
487
        user=user,
488
        scopes=' '.join(scopes),
489
        session_key='',
490
        expired=start + expires_in)
491
    # make id_token
492
    id_token = utils.create_user_info(
493
        request,
494
        client,
495
        user,
496
        scopes,
497
        id_token=True)
498
    id_token.update({
499
        'iss': utils.get_issuer(request),
500
        'aud': client.client_id,
501
        'exp': timestamp_from_datetime(start + idtoken_duration(client)),
502
        'iat': timestamp_from_datetime(start),
503
        'auth_time': timestamp_from_datetime(start),
504
        'acr': '0',
505
    })
506
    return JsonResponse({
507
        'access_token': six.text_type(access_token.uuid),
508
        'token_type': 'Bearer',
509
        'expires_in': expires_in.total_seconds(),
510
        'id_token': utils.make_idtoken(client, id_token),
511
    })
512

  
513

  
514
def tokens_from_authz_code(request):
385 515
    code = request.POST.get('code')
386 516
    if code is None:
387
        return invalid_request('missing code')
517
        return invalid_request_response('missing code')
388 518
    try:
389 519
        oidc_code = models.OIDCCode.objects.select_related().get(uuid=code)
390 520
    except models.OIDCCode.DoesNotExist:
391
        return invalid_request('invalid code')
521
        return invalid_request_response('invalid code')
392 522
    if not oidc_code.is_valid():
393
        return invalid_request('code has expired or user is disconnected')
523
        return invalid_request_response('code has expired or user is disconnected')
394 524
    client = authenticate_client(request, client=oidc_code.client)
395 525
    if client is None:
396 526
        return HttpResponse('unauthenticated', status=401)
......
398 528
    models.OIDCCode.objects.filter(uuid=code).delete()
399 529
    redirect_uri = request.POST.get('redirect_uri')
400 530
    if oidc_code.redirect_uri != redirect_uri:
401
        return invalid_request('invalid redirect_uri')
402
    expires_in = 3600 * 8
531
        return invalid_request_response('invalid redirect_uri')
532
    expires_in = access_token_duration(client)
403 533
    access_token = models.OIDCAccessToken.objects.create(
404 534
        client=client,
405 535
        user=oidc_code.user,
406 536
        scopes=oidc_code.scopes,
407 537
        session_key=oidc_code.session_key,
408
        expired=oidc_code.created + datetime.timedelta(seconds=expires_in))
538
        expired=oidc_code.created + expires_in)
409 539
    start = now()
410 540
    acr = '0'
411 541
    if (oidc_code.nonce is not None
......
429 559
    })
430 560
    if oidc_code.nonce is not None:
431 561
        id_token['nonce'] = oidc_code.nonce
432
    response = HttpResponse(json.dumps({
562
    return JsonResponse({
433 563
        'access_token': six.text_type(access_token.uuid),
434 564
        'token_type': 'Bearer',
435
        'expires_in': expires_in,
565
        'expires_in': expires_in.total_seconds(),
436 566
        'id_token': utils.make_idtoken(client, id_token),
437
    }), content_type='application/json')
567
    })
568

  
569

  
570
@setting_enabled('ENABLE', settings=app_settings)
571
@csrf_exempt
572
def token(request, *args, **kwargs):
573
    if request.method != 'POST':
574
        return HttpResponseNotAllowed(['POST'])
575
    grant_type = request.POST.get('grant_type')
576
    if grant_type == 'password':
577
        response = idtoken_from_user_credential(request)
578
    elif grant_type == 'authorization_code':
579
        response = tokens_from_authz_code(request)
580
    else:
581
        return invalid_request_response('grant_type must be either authorization_code or password')
438 582
    response['Cache-Control'] = 'no-store'
439 583
    response['Pragma'] = 'no-cache'
440 584
    return response
......
465 609
                                       access_token.client,
466 610
                                       access_token.user,
467 611
                                       access_token.scope_set())
468
    return HttpResponse(json.dumps(user_info), content_type='application/json')
612
    return JsonResponse(user_info)
469 613

  
470 614

  
471 615
@setting_enabled('ENABLE', settings=app_settings)
tests/test_idp_oidc.py
25 25

  
26 26
import utils
27 27

  
28
from django.core.cache import cache
28 29
from django.core.urlresolvers import reverse
29 30
from django.core.files import File
30 31
from django.db import connection
31 32
from django.db.migrations.executor import MigrationExecutor
32 33
from django.utils.timezone import now
34
from django.test.client import RequestFactory
33 35
from django.contrib.auth import get_user_model
34 36
from django.utils.six.moves.urllib import parse as urlparse
37
from ratelimit.utils import is_ratelimited
35 38

  
36 39

  
37 40
User = get_user_model()
38 41

  
39 42
from authentic2.models import Attribute, AuthorizedRole
40 43
from authentic2_idp_oidc.models import OIDCClient, OIDCAuthorization, OIDCCode, OIDCAccessToken, OIDCClaim
41
from authentic2_idp_oidc.utils import make_sub
44
from authentic2_idp_oidc.utils import (make_sub, get_first_rsa_sig_key,
45
        base64url)
42 46
from authentic2.a2_rbac.utils import get_default_ou
43 47
from authentic2.utils import make_url
44 48
from authentic2_auth_oidc.utils import parse_timestamp
......
66 70
@pytest.fixture
67 71
def oidc_settings(settings):
68 72
    settings.A2_IDP_OIDC_JWKSET = JWKSET
73
    settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT = '100/m'
69 74
    return settings
70 75

  
71 76

  
......
667 672
        }, headers=client_authentication_headers(oidc_client), status=400)
668 673
        assert 'error' in response.json
669 674
        assert response.json['error'] == 'invalid_request'
670
        assert response.json['desc'] == 'code has expired or user is disconnected'
675
        assert response.json['error_description'] == 'code has expired or user is disconnected'
671 676

  
672 677
    # invalid logout
673 678
    logout_url = make_url('oidc-logout', params={
......
693 698
        }, headers=client_authentication_headers(oidc_client), status=400)
694 699
        assert 'error' in response.json
695 700
        assert response.json['error'] == 'invalid_request'
696
        assert response.json['desc'] == 'code has expired or user is disconnected'
701
        assert response.json['error_description'] == 'code has expired or user is disconnected'
697 702

  
698 703

  
699 704
def test_expired_manager(db, simple_user):
......
1161 1166

  
1162 1167
    response = app.get('/api/users/')
1163 1168
    assert len(response.json['results']) == count
1169

  
1170

  
1171
def test_credentials_grant(app, oidc_client, admin, simple_user):
1172
    cache.clear()
1173
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1174
    oidc_client.scope = 'openid'
1175
    oidc_client.save()
1176
    token_url = make_url('oidc-token')
1177
    if oidc_client.idtoken_algo == OIDCClient.ALGO_HMAC:
1178
        jwk = JWK(kty='oct', k=base64url(oidc_client.client_secret.encode('utf-8')))
1179
    elif oidc_client.idtoken_algo == OIDCClient.ALGO_RSA:
1180
        jwk = get_first_rsa_sig_key()
1181

  
1182
    # 1. test in-request client credentials
1183
    params = {
1184
        'client_id': oidc_client.client_id,
1185
        'client_secret': oidc_client.client_secret,
1186
        'grant_type': 'password',
1187
        'username': simple_user.username,
1188
        'password': simple_user.username,
1189
    }
1190
    response = app.post(token_url, params=params)
1191
    assert 'id_token' in response.json
1192
    token = response.json['id_token']
1193
    header, payload, signature = token.split('.')
1194
    jwt = JWT()
1195
    jwt.deserialize(token, key=jwk)
1196
    claims = json.loads(jwt.claims)
1197
    # xxx already verified by jwcrypto deserialization?
1198
    assert set(claims) == set(['acr', 'aud', 'auth_time', 'exp', 'iat', 'iss', 'sub'])
1199
    assert all(claims.values())
1200

  
1201
    # 2. test basic authz
1202
    params.pop('client_id')
1203
    params.pop('client_secret')
1204

  
1205
    response = app.post(token_url, params=params, headers=client_authentication_headers(oidc_client))
1206
    assert 'id_token' in response.json
1207
    token = response.json['id_token']
1208
    header, payload, signature = token.split('.')
1209
    jwt = JWT()
1210
    jwt.deserialize(token, key=jwk)
1211
    claims = json.loads(jwt.claims)
1212
    # xxx already verified by jwcrypto deserialization?
1213
    assert set(claims) == set(['acr', 'aud', 'auth_time', 'exp', 'iat', 'iss', 'sub'])
1214
    assert all(claims.values())
1215

  
1216

  
1217
def test_credentials_grant_ratelimitation_invalid_client(
1218
        app, oidc_client, admin, simple_user, oidc_settings, freezer):
1219
    freezer.move_to('2020-01-01')
1220

  
1221
    cache.clear()
1222
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1223
    oidc_client.save()
1224
    token_url = make_url('oidc-token')
1225
    params = {
1226
        'client_id': oidc_client.client_id,
1227
        'client_secret': 'notgood',
1228
        'grant_type': 'password',
1229
        'username': simple_user.username,
1230
        'password': simple_user.username,
1231
    }
1232
    for i in range(int(oidc_settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT.split('/')[0])):
1233
        response = app.post(token_url, params=params, status=400)
1234
        assert response.json['error'] == 'invalid_client'
1235
        assert 'client authentication failed' in response.json['error_description']
1236
    response = app.post(token_url, params=params, status=400)
1237
    assert response.json['error'] == 'invalid_request'
1238
    assert 'reached rate limitation' in response.json['error_description']
1239

  
1240

  
1241
def test_credentials_grant_ratelimitation_valid_client(
1242
        app, oidc_client, admin, simple_user, oidc_settings, freezer):
1243
    freezer.move_to('2020-01-01')
1244

  
1245
    cache.clear()
1246
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1247
    oidc_client.save()
1248
    token_url = make_url('oidc-token')
1249
    params = {
1250
        'client_id': oidc_client.client_id,
1251
        'client_secret': oidc_client.client_secret,
1252
        'grant_type': 'password',
1253
        'username': simple_user.username,
1254
        'password': simple_user.username,
1255
    }
1256
    for i in range(int(oidc_settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT.split('/')[0])):
1257
        app.post(token_url, params=params)
1258
    response = app.post(token_url, params=params, status=400)
1259
    assert response.json['error'] == 'invalid_request'
1260
    assert 'reached rate limitation' in response.json['error_description']
1261

  
1262

  
1263
def test_credentials_grant_retrytimout(
1264
        app, oidc_client, admin, simple_user, settings, freezer):
1265
    freezer.move_to('2020-01-01')
1266

  
1267
    cache.clear()
1268
    settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 2
1269
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1270
    oidc_client.save()
1271
    token_url = make_url('oidc-token')
1272
    params = {
1273
        'client_id': oidc_client.client_id,
1274
        'client_secret': oidc_client.client_secret,
1275
        'grant_type': 'password',
1276
        'username': simple_user.username,
1277
        'password': u'SurelyNotTheRightPassword',
1278
    }
1279
    attempts = 0
1280
    while attempts < 100:
1281
        response = app.post(token_url, params=params, status=400)
1282
        attempts += 1
1283
        if attempts >= 10:
1284
            assert response.json['error'] == 'invalid_request'
1285
            assert 'too many attempts with erroneous RO password' in response.json['error_description']
1286

  
1287
    # freeze some time after backoff delay expiration
1288
    freezer.move_to(datetime.timedelta(days=2))
1289

  
1290
    # obtain a successful login
1291
    params['password'] = simple_user.username
1292
    response = app.post(token_url, params=params, status=200)
1293
    assert 'id_token' in response.json
1294

  
1295

  
1296
def test_credentials_grant_invalid_flow(
1297
        app, oidc_client, admin, simple_user, settings):
1298
    cache.clear()
1299
    params = {
1300
        'client_id': oidc_client.client_id,
1301
        'client_secret': oidc_client.client_secret,
1302
        'grant_type': 'password',
1303
        'username': simple_user.username,
1304
        'password': u'SurelyNotTheRightPassword',
1305
    }
1306
    token_url = make_url('oidc-token')
1307
    response = app.post(token_url, params=params, status=400)
1308
    assert response.json['error'] == 'unauthorized_client'
1309
    assert 'is not configured' in response.json['error_description']
1310

  
1311

  
1312
def test_credentials_grant_invalid_client(
1313
        app, oidc_client, admin, simple_user, settings):
1314
    cache.clear()
1315
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1316
    oidc_client.save()
1317
    params = {
1318
        'client_id': oidc_client.client_id,
1319
        'client_secret': 'tryingthis',  # Nope, wrong secret
1320
        'grant_type': 'password',
1321
        'username': simple_user.username,
1322
        'password': simple_user.username,
1323
    }
1324
    token_url = make_url('oidc-token')
1325
    response = app.post(token_url, params=params, status=400)
1326
    assert response.json['error'] == 'invalid_client'
1327
    assert response.json['error_description'] == 'client authentication failed'
1328

  
1329

  
1330
def test_credentials_grant_unauthz_client(
1331
        app, oidc_client, admin, simple_user, settings):
1332
    cache.clear()
1333
    params = {
1334
        'client_id': oidc_client.client_id,
1335
        'client_secret': oidc_client.client_secret,
1336
        'grant_type': 'password',
1337
        'username': simple_user.username,
1338
        'password': simple_user.username,
1339
    }
1340
    token_url = make_url('oidc-token')
1341
    response = app.post(token_url, params=params, status=400)
1342
    assert response.json['error'] == 'unauthorized_client'
1343
    assert 'client is not configured for resource owner'in response.json['error_description']
1344

  
1345

  
1346
def test_credentials_grant_invalid_content_type(
1347
        app, oidc_client, admin, simple_user, settings):
1348
    cache.clear()
1349
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1350
    oidc_client.save()
1351
    params = {
1352
        'client_id': oidc_client.client_id,
1353
        'client_secret': oidc_client.client_secret,
1354
        'grant_type': 'password',
1355
        'username': simple_user.username,
1356
        'password': simple_user.username,
1357
    }
1358
    token_url = make_url('oidc-token')
1359
    response = app.post(
1360
        token_url, params=params,
1361
        content_type='multipart/form-data',
1362
        status=400)
1363
    assert response.json['error'] == 'invalid_request'
1364
    assert 'wrong content type' in response.json['error_description']
1164
-