support multiple response types per client

The Dynamic Client Registration spec specifies multiple response_types
and grant_types per client
(https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata).
Since grant_types can be inferred from response_types we should be able
to support both without needing to store grant_types.

This also helps with oidc-client-js which expects a client that supports
both "id_token" and "id_token token".
This commit is contained in:
Andy Clayton 2018-08-15 15:43:48 -05:00
parent b5e055205c
commit 36018d19ae
8 changed files with 132 additions and 26 deletions

View file

@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.
Unreleased
==========
* Added: support multiple response types per client.
0.6.2
=====

View file

@ -52,7 +52,7 @@ class ClientAdmin(admin.ModelAdmin):
fieldsets = [
[_(u''), {
'fields': (
'name', 'owner', 'client_type', 'response_type', '_redirect_uris', 'jwt_alg',
'name', 'owner', 'client_type', 'response_types', '_redirect_uris', 'jwt_alg',
'require_consent', 'reuse_consent'),
}],
[_(u'Credentials'), {
@ -66,7 +66,7 @@ class ClientAdmin(admin.ModelAdmin):
}],
]
form = ClientForm
list_display = ['name', 'client_id', 'response_type', 'date_created']
list_display = ['name', 'client_id', 'response_type_descriptions', 'date_created']
readonly_fields = ['date_created']
search_fields = ['name']
raw_id_fields = ['owner']

View file

@ -115,7 +115,8 @@ class AuthorizeEndpoint(object):
raise AuthorizeError(self.params['redirect_uri'], 'invalid_request', self.grant_type)
# Response type parameter validation.
if self.is_authentication and self.params['response_type'] != self.client.response_type:
if self.is_authentication \
and self.params['response_type'] not in self.client.response_type_values():
raise AuthorizeError(self.params['redirect_uri'], 'invalid_request', self.grant_type)
# PKCE validation of the transformation method.

View file

@ -0,0 +1,51 @@
# Generated by Django 2.0.7 on 2018-08-15 20:44
from django.db import migrations, models
def migrate_response_type(apps, schema_editor):
RESPONSE_TYPES = [
('code', 'code (Authorization Code Flow)'),
('id_token', 'id_token (Implicit Flow)'),
('id_token token', 'id_token token (Implicit Flow)'),
('code token', 'code token (Hybrid Flow)'),
('code id_token', 'code id_token (Hybrid Flow)'),
('code id_token token', 'code id_token token (Hybrid Flow)'),
]
# ensure we get proper, versioned model with the deleted response_type field;
# importing directly yields the latest without response_type
ResponseType = apps.get_model('oidc_provider', 'ResponseType')
Client = apps.get_model('oidc_provider', 'Client')
for value, description in RESPONSE_TYPES:
ResponseType.objects.create(value=value, description=description)
for client in Client.objects.all():
client.response_types.add(ResponseType.objects.get(value=client.response_type))
class Migration(migrations.Migration):
dependencies = [
('oidc_provider', '0025_user_field_codetoken'),
]
operations = [
migrations.CreateModel(
name='ResponseType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, unique=True, verbose_name='Response Type Value')),
('description', models.CharField(max_length=50)),
],
),
migrations.AddField(
model_name='client',
name='response_types',
field=models.ManyToManyField(to='oidc_provider.ResponseType'),
),
# omitting reverse migrate_response_type since removing response_type is irreversible (nonnull and no default)
migrations.RunPython(migrate_response_type),
migrations.RemoveField(
model_name='client',
name='response_type',
),
]

View file

@ -30,6 +30,20 @@ JWT_ALGS = [
]
class ResponseType(models.Model):
value = models.CharField(
max_length=30,
choices=RESPONSE_TYPE_CHOICES,
unique=True,
verbose_name=_(u'Response Type Value'))
description = models.CharField(
max_length=50,
)
def __str__(self):
return u'{0}'.format(self.description)
class Client(models.Model):
name = models.CharField(max_length=100, default='', verbose_name=_(u'Name'))
@ -45,8 +59,7 @@ class Client(models.Model):
u' of their credentials. <b>Public</b> clients are incapable.'))
client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID'))
client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET'))
response_type = models.CharField(
max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type'))
response_types = models.ManyToManyField(ResponseType)
jwt_alg = models.CharField(
max_length=10,
choices=JWT_ALGS,
@ -99,6 +112,12 @@ class Client(models.Model):
def __unicode__(self):
return self.__str__()
def response_type_values(self):
return (response_type.value for response_type in self.response_types.all())
def response_type_descriptions(self):
return [response_type.description for response_type in self.response_types.all()]
@property
def redirect_uris(self):
return self._redirect_uris.splitlines()

View file

@ -15,7 +15,8 @@ from django.contrib.auth.models import User
from oidc_provider.models import (
Client,
Code,
Token)
Token,
ResponseType)
FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d'
@ -58,12 +59,16 @@ def create_fake_client(response_type, is_public=False, require_consent=True):
client.client_secret = ''
else:
client.client_secret = str(random.randint(1, 999999)).zfill(6)
client.response_type = response_type
client.redirect_uris = ['http://example.com/']
client.require_consent = require_consent
client.save()
if isinstance(response_type, ("".__class__, u"".__class__)):
response_type = (response_type,)
for value in response_type:
client.response_types.add(ResponseType.objects.get(value=value))
return client

View file

@ -349,7 +349,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client.client_id,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'redirect_uri': self.client.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -376,7 +376,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client.client_id,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'redirect_uri': self.client.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -410,7 +410,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client.client_id,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'redirect_uri': self.client.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -432,7 +432,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client.client_id,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'redirect_uri': self.client.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -455,7 +455,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client.client_id,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'redirect_uri': self.client.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -485,6 +485,8 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
require_consent=False)
self.client_no_access = create_fake_client(response_type='id_token')
self.client_public_no_access = create_fake_client(response_type='id_token', is_public=True)
self.client_multiple_response_types = create_fake_client(
response_type=('id_token', 'id_token token'))
self.state = uuid.uuid4().hex
self.nonce = uuid.uuid4().hex
@ -494,7 +496,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client.client_id,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'redirect_uri': self.client.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -512,7 +514,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
data = {
'client_id': self.client.client_id,
'redirect_uri': self.client.default_redirect_uri,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
@ -527,7 +529,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
# same for public client
data['client_id'] = self.client_public.client_id,
data['redirect_uri'] = self.client_public.default_redirect_uri,
data['response_type'] = self.client_public.response_type,
data['response_type'] = next(self.client_public.response_type_values()),
response = self._auth_request('post', data, is_user_authenticated=True)
@ -542,7 +544,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
data = {
'client_id': self.client_no_access.client_id,
'redirect_uri': self.client_no_access.default_redirect_uri,
'response_type': self.client_no_access.response_type,
'response_type': next(self.client_no_access.response_type_values()),
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
@ -557,7 +559,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
# same for public client
data['client_id'] = self.client_public_no_access.client_id,
data['redirect_uri'] = self.client_public_no_access.default_redirect_uri,
data['response_type'] = self.client_public_no_access.response_type,
data['response_type'] = next(self.client_public_no_access.response_type_values()),
response = self._auth_request('post', data, is_user_authenticated=True)
@ -572,7 +574,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
data = {
'client_id': self.client.client_id,
'redirect_uri': self.client.default_redirect_uri,
'response_type': self.client.response_type,
'response_type': next(self.client.response_type_values()),
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
@ -598,7 +600,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
data = {
'client_id': self.client_no_access.client_id,
'redirect_uri': self.client_no_access.default_redirect_uri,
'response_type': self.client_no_access.response_type,
'response_type': next(self.client_no_access.response_type_values()),
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
@ -622,7 +624,7 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
data = {
'client_id': self.client_public_no_consent.client_id,
'response_type': self.client_public_no_consent.response_type,
'response_type': next(self.client_public_no_consent.response_type_values()),
'redirect_uri': self.client_public_no_consent.default_redirect_uri,
'scope': 'openid email',
'state': self.state,
@ -638,6 +640,33 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
self.assertIn('id_token', fragment)
self.assertIn('expires_in', fragment)
def test_multiple_response_types(self):
"""
Clients should be able to be configured for multiple response types.
"""
data = {
'client_id': self.client_multiple_response_types.client_id,
'redirect_uri': self.client_multiple_response_types.default_redirect_uri,
'response_type': 'id_token',
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
'allow': 'Accept',
}
response = self._auth_request('post', data, is_user_authenticated=True)
self.assertNotIn('access_token', response['Location'])
self.assertIn('id_token', response['Location'])
# should also support "id_token token" response_type
data['response_type'] = 'id_token token'
response = self._auth_request('post', data, is_user_authenticated=True)
self.assertIn('access_token', response['Location'])
self.assertIn('id_token', response['Location'])
class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
@ -657,7 +686,7 @@ class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin):
self.data = {
'client_id': self.client_code_idtoken_token.client_id,
'redirect_uri': self.client_code_idtoken_token.default_redirect_uri,
'response_type': self.client_code_idtoken_token.response_type,
'response_type': next(self.client_code_idtoken_token.response_type_values()),
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
@ -703,7 +732,7 @@ class TestCreateResponseURI(TestCase):
data = {
'client_id': client.client_id,
'redirect_uri': client.default_redirect_uri,
'response_type': client.response_type,
'response_type': next(client.response_type_values()),
}
factory = RequestFactory()

View file

@ -49,9 +49,8 @@ from oidc_provider.lib.utils.oauth2 import protected_resource_view
from oidc_provider.lib.utils.token import client_id_from_id_token
from oidc_provider.models import (
Client,
RESPONSE_TYPE_CHOICES,
RSAKey,
)
ResponseType)
from oidc_provider import settings
from oidc_provider import signals
@ -105,7 +104,7 @@ class AuthorizeView(View):
implicit_flow_resp_types = {'id_token', 'id_token token'}
allow_skipping_consent = (
authorize.client.client_type != 'public' or
authorize.client.response_type in implicit_flow_resp_types)
authorize.params['response_type'] in implicit_flow_resp_types)
if not authorize.client.require_consent and (
allow_skipping_consent and
@ -283,7 +282,7 @@ class ProviderInfoView(View):
dic['end_session_endpoint'] = site_url + reverse('oidc_provider:end-session')
dic['introspection_endpoint'] = site_url + reverse('oidc_provider:token-introspection')
types_supported = [x[0] for x in RESPONSE_TYPE_CHOICES]
types_supported = [response_type.value for response_type in ResponseType.objects.all()]
dic['response_types_supported'] = types_supported
dic['jwks_uri'] = site_url + reverse('oidc_provider:jwks')