Skip to content
This repository was archived by the owner on Mar 3, 2020. It is now read-only.

Commit 199b0ed

Browse files
committed
Added support for client credentials grant
1 parent f2ce3a5 commit 199b0ed

File tree

5 files changed

+104
-4
lines changed

5 files changed

+104
-4
lines changed

provider/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.4.0'
1+
__version__ = '0.5.0'

provider/oauth2/forms.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.utils.encoding import smart_unicode
44
from django.utils.translation import ugettext as _
55

6-
from provider import scope
6+
from provider import constants, scope
77
from provider.constants import RESPONSE_TYPE_CHOICES, SCOPES
88
from provider.forms import OAuthForm, OAuthValidationError
99
from provider.oauth2.models import Client, Grant, RefreshToken
@@ -336,3 +336,14 @@ def clean(self):
336336

337337
data['client'] = client
338338
return data
339+
340+
341+
class ClientCredentialsGrantForm(ScopeMixin, OAuthForm):
342+
""" Validate a client credentials grant request. """
343+
344+
def clean(self):
345+
cleaned_data = super(ClientCredentialsGrantForm, self).clean()
346+
# We do not fully support scopes for this grant type; however, a scope is required
347+
# in order to create an access token. Default to read-only access.
348+
cleaned_data['scope'] = constants.READ
349+
return cleaned_data

provider/oauth2/tests.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,71 @@ def test_access_token_response_valid_token_type(self):
588588
self.assertEqual(token['token_type'], constants.TOKEN_TYPE, token)
589589

590590

591+
@ddt.ddt
592+
class ClientCredentialsAccessTokenTests(BaseOAuth2TestCase):
593+
""" Tests for issuing access tokens using the client credentials grant. """
594+
fixtures = ['test_oauth2.json']
595+
596+
def setUp(self):
597+
super(ClientCredentialsAccessTokenTests, self).setUp()
598+
AccessToken.objects.all().delete()
599+
600+
def request_access_token(self, client_id=None, client_secret=None):
601+
""" Issues an access token request using the client credentials grant.
602+
603+
Arguments:
604+
client_id (str): Optional override of the client ID credential.
605+
client_secret (str): Optional override of the client secret credential.
606+
607+
Returns:
608+
HttpResponse
609+
"""
610+
client = self.get_client()
611+
data = {
612+
'grant_type': 'client_credentials',
613+
'client_id': client_id or client.client_id,
614+
'client_secret': client_secret or client.client_secret,
615+
}
616+
617+
return self.client.post(self.access_token_url(), data)
618+
619+
def assert_valid_access_token_response(self, access_token, response):
620+
""" Verifies the content of the response contains a JSON representation of the access token.
621+
622+
Note:
623+
The access token should NOT have an associated refresh token.
624+
"""
625+
expected = {
626+
u'access_token': access_token.token,
627+
u'token_type': constants.TOKEN_TYPE,
628+
u'expires_in': access_token.get_expire_delta(),
629+
u'scope': u' '.join(scope.names(access_token.scope)),
630+
}
631+
632+
self.assertEqual(json.loads(response.content), expected)
633+
634+
def get_latest_access_token(self):
635+
return AccessToken.objects.filter(client=self.get_client()).order_by('-id')[0]
636+
637+
def test_authorize_success(self):
638+
""" Verify the endpoint successfully issues an access token using the client credentials grant. """
639+
response = self.request_access_token()
640+
self.assertEqual(200, response.status_code, response.content)
641+
642+
access_token = self.get_latest_access_token()
643+
self.assert_valid_access_token_response(access_token, response)
644+
645+
@ddt.data(
646+
{'client_id': 'invalid'},
647+
{'client_secret': 'invalid'},
648+
)
649+
def test_authorize_with_invalid_credentials(self, credentials_override):
650+
""" Verify the endpoint returns HTTP 400 if the credentials are invalid. """
651+
response = self.request_access_token(**credentials_override)
652+
self.assertEqual(400, response.status_code, response.content)
653+
self.assertDictEqual(json.loads(response.content), {'error': 'invalid_client'})
654+
655+
591656
class AuthBackendTest(BaseOAuth2TestCase):
592657
fixtures = ['test_oauth2']
593658

provider/oauth2/views.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from provider import constants
1010
from provider.oauth2.backends import BasicClientBackend, RequestParamsClientBackend, PublicPasswordBackend
1111
from provider.oauth2.forms import (AuthorizationCodeGrantForm, AuthorizationRequestForm, AuthorizationForm,
12-
PasswordGrantForm, RefreshTokenGrantForm)
12+
PasswordGrantForm, RefreshTokenGrantForm, ClientCredentialsGrantForm)
1313
from provider.oauth2.models import Client, RefreshToken, AccessToken
1414
from provider.utils import now
1515
from provider.views import AccessToken as AccessTokenView, OAuthError, AccessTokenMixin, Capture, Authorize, Redirect
@@ -139,6 +139,12 @@ def get_password_grant(self, request, data, client):
139139
raise OAuthError(form.errors)
140140
return form.cleaned_data
141141

142+
def get_client_credentials_grant(self, request, data, client):
143+
form = ClientCredentialsGrantForm(data, client=client)
144+
if not form.is_valid():
145+
raise OAuthError(form.errors)
146+
return form.cleaned_data
147+
142148
def invalidate_grant(self, grant):
143149
if constants.DELETE_EXPIRED:
144150
grant.delete()

provider/views.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ class AccessToken(OAuthView, Mixin, AccessTokenMixin):
503503
Authentication backends used to authenticate a particular client.
504504
"""
505505

506-
grant_types = ['authorization_code', 'refresh_token', 'password']
506+
grant_types = ['authorization_code', 'refresh_token', 'password', 'client_credentials']
507507
"""
508508
The default grant types supported by this view.
509509
"""
@@ -532,6 +532,14 @@ def get_password_grant(self, request, data, client):
532532
"""
533533
raise NotImplementedError # pragma: no cover
534534

535+
def get_client_credentials_grant(self, request, data, client):
536+
"""
537+
Return the optional parameters (scope) associated with this request.
538+
539+
:return: ``tuple`` - ``(True or False, options)``
540+
"""
541+
raise NotImplementedError
542+
535543
def invalidate_grant(self, grant):
536544
"""
537545
Override to handle grant invalidation. A grant is invalidated right
@@ -610,6 +618,14 @@ def password(self, request, data, client):
610618

611619
return self.access_token_response(at)
612620

621+
def client_credentials(self, request, data, client):
622+
""" Handle ``grant_type=client_credentials`` requests as defined in :rfc:`4.4`. """
623+
data = self.get_client_credentials_grant(request, data, client)
624+
scope = data.get('scope')
625+
at = self.get_access_token(request, client.user, scope, client)
626+
627+
return self.access_token_response(at)
628+
613629
def get_handler(self, grant_type):
614630
"""
615631
Return a function or method that is capable handling the ``grant_type``
@@ -622,6 +638,8 @@ def get_handler(self, grant_type):
622638
return self.refresh_token
623639
elif grant_type == 'password':
624640
return self.password
641+
elif grant_type == 'client_credentials':
642+
return self.client_credentials
625643
return None
626644

627645
def get(self, request):

0 commit comments

Comments
 (0)