diff --git a/provider/__init__.py b/provider/__init__.py index abeeedbf..2b8877c5 100644 --- a/provider/__init__.py +++ b/provider/__init__.py @@ -1 +1 @@ -__version__ = '0.4.0' +__version__ = '0.5.0' diff --git a/provider/oauth2/forms.py b/provider/oauth2/forms.py index aab7b01b..dcd5a69e 100644 --- a/provider/oauth2/forms.py +++ b/provider/oauth2/forms.py @@ -3,7 +3,7 @@ from django.utils.encoding import smart_unicode from django.utils.translation import ugettext as _ -from provider import scope +from provider import constants, scope from provider.constants import RESPONSE_TYPE_CHOICES, SCOPES from provider.forms import OAuthForm, OAuthValidationError from provider.oauth2.models import Client, Grant, RefreshToken @@ -336,3 +336,14 @@ def clean(self): data['client'] = client return data + + +class ClientCredentialsGrantForm(ScopeMixin, OAuthForm): + """ Validate a client credentials grant request. """ + + def clean(self): + cleaned_data = super(ClientCredentialsGrantForm, self).clean() + # We do not fully support scopes for this grant type; however, a scope is required + # in order to create an access token. Default to read-only access. + cleaned_data['scope'] = constants.READ + return cleaned_data diff --git a/provider/oauth2/tests.py b/provider/oauth2/tests.py index 5ea494fb..96caf86d 100644 --- a/provider/oauth2/tests.py +++ b/provider/oauth2/tests.py @@ -588,6 +588,71 @@ def test_access_token_response_valid_token_type(self): self.assertEqual(token['token_type'], constants.TOKEN_TYPE, token) +@ddt.ddt +class ClientCredentialsAccessTokenTests(BaseOAuth2TestCase): + """ Tests for issuing access tokens using the client credentials grant. """ + fixtures = ['test_oauth2.json'] + + def setUp(self): + super(ClientCredentialsAccessTokenTests, self).setUp() + AccessToken.objects.all().delete() + + def request_access_token(self, client_id=None, client_secret=None): + """ Issues an access token request using the client credentials grant. + + Arguments: + client_id (str): Optional override of the client ID credential. + client_secret (str): Optional override of the client secret credential. + + Returns: + HttpResponse + """ + client = self.get_client() + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id or client.client_id, + 'client_secret': client_secret or client.client_secret, + } + + return self.client.post(self.access_token_url(), data) + + def assert_valid_access_token_response(self, access_token, response): + """ Verifies the content of the response contains a JSON representation of the access token. + + Note: + The access token should NOT have an associated refresh token. + """ + expected = { + u'access_token': access_token.token, + u'token_type': constants.TOKEN_TYPE, + u'expires_in': access_token.get_expire_delta(), + u'scope': u' '.join(scope.names(access_token.scope)), + } + + self.assertEqual(json.loads(response.content), expected) + + def get_latest_access_token(self): + return AccessToken.objects.filter(client=self.get_client()).order_by('-id')[0] + + def test_authorize_success(self): + """ Verify the endpoint successfully issues an access token using the client credentials grant. """ + response = self.request_access_token() + self.assertEqual(200, response.status_code, response.content) + + access_token = self.get_latest_access_token() + self.assert_valid_access_token_response(access_token, response) + + @ddt.data( + {'client_id': 'invalid'}, + {'client_secret': 'invalid'}, + ) + def test_authorize_with_invalid_credentials(self, credentials_override): + """ Verify the endpoint returns HTTP 400 if the credentials are invalid. """ + response = self.request_access_token(**credentials_override) + self.assertEqual(400, response.status_code, response.content) + self.assertDictEqual(json.loads(response.content), {'error': 'invalid_client'}) + + class AuthBackendTest(BaseOAuth2TestCase): fixtures = ['test_oauth2'] diff --git a/provider/oauth2/views.py b/provider/oauth2/views.py index 0d51a4df..c9535db9 100644 --- a/provider/oauth2/views.py +++ b/provider/oauth2/views.py @@ -9,7 +9,7 @@ from provider import constants from provider.oauth2.backends import BasicClientBackend, RequestParamsClientBackend, PublicPasswordBackend from provider.oauth2.forms import (AuthorizationCodeGrantForm, AuthorizationRequestForm, AuthorizationForm, - PasswordGrantForm, RefreshTokenGrantForm) + PasswordGrantForm, RefreshTokenGrantForm, ClientCredentialsGrantForm) from provider.oauth2.models import Client, RefreshToken, AccessToken from provider.utils import now from provider.views import AccessToken as AccessTokenView, OAuthError, AccessTokenMixin, Capture, Authorize, Redirect @@ -24,7 +24,6 @@ def get_access_token(self, request, user, scope, client): except AccessToken.DoesNotExist: # None found... make a new one! at = self.create_access_token(request, user, scope, client) - self.create_refresh_token(request, user, scope, at, client) return at def create_access_token(self, request, user, scope, client): @@ -140,6 +139,12 @@ def get_password_grant(self, request, data, client): raise OAuthError(form.errors) return form.cleaned_data + def get_client_credentials_grant(self, request, data, client): + form = ClientCredentialsGrantForm(data, client=client) + if not form.is_valid(): + raise OAuthError(form.errors) + return form.cleaned_data + def invalidate_grant(self, grant): if constants.DELETE_EXPIRED: grant.delete() diff --git a/provider/views.py b/provider/views.py index 8bca5d13..00e7f62f 100644 --- a/provider/views.py +++ b/provider/views.py @@ -105,6 +105,25 @@ def access_token_response_data(self, access_token, response_type=None): return response_data + def get_access_and_refresh_tokens(self, request, user, scope, client, reuse_existing_access_token=False, create_refresh_token=True): + """ + Returns an AccessToken and RefreshToken for the given user, scope, and client combination. + + Returns: + (AccessToken, RefreshToken) + If create_refresh_token is False, the second element of the tuple will be None. + """ + if reuse_existing_access_token: + at = self.get_access_token(request, user, scope, client) + else: + at = self.create_access_token(request, user, scope, client) + + rt = None + if create_refresh_token and not reuse_existing_access_token: + rt = self.create_refresh_token(request, user, scope, at, client) + + return at, rt + class OAuthView(TemplateView): """ @@ -333,15 +352,14 @@ def get_implicit_response(self, request, client): data = self.get_data(request) lookup_kwargs = { - "user": request.user, - "client": client, - "scope": scope.to_int(*data.get('scope', constants.SCOPES[0][1]).split()) + 'user': request.user, + 'client': client, + 'scope': scope.to_int(*data.get('scope', constants.SCOPES[0][1]).split()), + 'reuse_existing_access_token': constants.SINGLE_ACCESS_TOKEN, + 'create_refresh_token': False } - if constants.SINGLE_ACCESS_TOKEN: - token = self.get_access_token(request, **lookup_kwargs) - else: - token = self.create_access_token(request, **lookup_kwargs) + token, __ = self.get_access_and_refresh_tokens(request, **lookup_kwargs) response_data = self.access_token_response_data(token, data['response_type']) @@ -503,7 +521,7 @@ class AccessToken(OAuthView, Mixin, AccessTokenMixin): Authentication backends used to authenticate a particular client. """ - grant_types = ['authorization_code', 'refresh_token', 'password'] + grant_types = ['authorization_code', 'refresh_token', 'password', 'client_credentials'] """ The default grant types supported by this view. """ @@ -532,6 +550,14 @@ def get_password_grant(self, request, data, client): """ raise NotImplementedError # pragma: no cover + def get_client_credentials_grant(self, request, data, client): + """ + Return the optional parameters (scope) associated with this request. + + :return: ``tuple`` - ``(True or False, options)`` + """ + raise NotImplementedError # pragma: no cover + def invalidate_grant(self, grant): """ Override to handle grant invalidation. A grant is invalidated right @@ -564,13 +590,16 @@ def authorization_code(self, request, data, client): Handle ``grant_type=authorization_code`` requests as defined in :rfc:`4.1.3`. """ - grant = self.get_authorization_code_grant(request, request.POST, - client) - if constants.SINGLE_ACCESS_TOKEN: - at = self.get_access_token(request, grant.user, grant.scope, client) - else: - at = self.create_access_token(request, grant.user, grant.scope, client) - rt = self.create_refresh_token(request, grant.user, grant.scope, at, client) + grant = self.get_authorization_code_grant(request, request.POST, client) + + kwargs = { + 'request': request, + 'user': grant.user, + 'scope': grant.scope, + 'client': client, + 'reuse_existing_access_token': constants.SINGLE_ACCESS_TOKEN, + } + at, rt = self.get_access_and_refresh_tokens(**kwargs) self.invalidate_grant(grant) @@ -586,8 +615,13 @@ def refresh_token(self, request, data, client): self.invalidate_refresh_token(rt) self.invalidate_access_token(rt.access_token) - at = self.create_access_token(request, rt.user, rt.access_token.scope, client) - rt = self.create_refresh_token(request, at.user, at.scope, at, client) + kwargs = { + 'request': request, + 'user': rt.user, + 'scope': rt.access_token.scope, + 'client': client, + } + at, rt = self.get_access_and_refresh_tokens(**kwargs) return self.access_token_response(at) @@ -597,16 +631,32 @@ def password(self, request, data, client): """ data = self.get_password_grant(request, data, client) - user = data.get('user') - scope = data.get('scope') + kwargs = { + 'request': request, + 'user': data.get('user'), + 'scope': data.get('scope'), + 'client': client, + 'reuse_existing_access_token': constants.SINGLE_ACCESS_TOKEN, - if constants.SINGLE_ACCESS_TOKEN: - at = self.get_access_token(request, user, scope, client) - else: - at = self.create_access_token(request, user, scope, client) # Public clients don't get refresh tokens - if client.client_type == constants.CONFIDENTIAL: - rt = self.create_refresh_token(request, user, scope, at, client) + 'create_refresh_token': client.client_type == constants.CONFIDENTIAL + } + at, rt = self.get_access_and_refresh_tokens(**kwargs) + + return self.access_token_response(at) + + def client_credentials(self, request, data, client): + """ Handle ``grant_type=client_credentials`` requests as defined in :rfc:`4.4`. """ + data = self.get_client_credentials_grant(request, data, client) + kwargs = { + 'request': request, + 'user': client.user, + 'scope': data.get('scope'), + 'client': client, + 'reuse_existing_access_token': constants.SINGLE_ACCESS_TOKEN, + 'create_refresh_token': False, + } + at, rt = self.get_access_and_refresh_tokens(**kwargs) return self.access_token_response(at) @@ -622,6 +672,8 @@ def get_handler(self, grant_type): return self.refresh_token elif grant_type == 'password': return self.password + elif grant_type == 'client_credentials': + return self.client_credentials return None def get(self, request):