diff --git a/.travis.yml b/.travis.yml index 3d5c324..443e5a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ python: - "3.5" - "3.6" env: - - DJANGO=1.7 - DJANGO=1.8 - DJANGO=1.9 - DJANGO=1.10 @@ -15,10 +14,6 @@ matrix: exclude: - python: "2.7" env: DJANGO=2.0 - - python: "3.5" - env: DJANGO=1.7 - - python: "3.6" - env: DJANGO=1.7 install: - pip install tox coveralls script: diff --git a/README.md b/README.md index c6bb6cd..074dc01 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Django OIDC Provider +# Django OpenID Connect Provider [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) @@ -11,7 +11,7 @@ OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, whic ## About the package -`django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. +`django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect (and OAuth2) capabilities to your Django projects. Support for Python 3 and 2. Also latest versions of django. diff --git a/docs/index.rst b/docs/index.rst index be652a5..05edb50 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,7 +39,6 @@ Contents: sections/signals sections/examples sections/contribute - sections/contribute sections/changelog .. diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 0c5f0f6..8ddf0be 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,11 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Added: token instrospection endpoint support (RFC7662). +* Added: request in password grant authenticate call. +* Changed: dropping support for Django versions before 1.8. +* Changed: pass token and request to OIDC_IDTOKEN_PROCESSING_HOOK. + 0.6.0 ===== diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index bf18a0a..3a92626 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -7,12 +7,12 @@ Requirements ============ * Python: ``2.7`` ``3.4`` ``3.5`` ``3.6`` -* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` +* Django: ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` Quick Installation ================== -If you want to get started fast see our ``/example_project`` folder in your local installation. Or look at it `on github `_. +If you want to get started fast see our ``/example`` folder in your local installation. Or look at it `on github `_. Install the package using pip:: diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index b5457c1..93ef62a 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -64,6 +64,13 @@ Used to add extra scopes specific for your app. OpenID Connect RP's will use sco Read more about how to implement it in :ref:`scopesclaims` section. +OIDC_IDTOKEN_INCLUDE_CLAIMS +============================== + +OPTIONAL. ``bool``. If enabled, id_token will include standard claims of the user (email, first name, etc.). + +Default is ``False``. + OIDC_IDTOKEN_EXPIRE =================== @@ -81,12 +88,27 @@ Here you can add extra dictionary values specific for your app into id_token. The ``list`` or ``tuple`` is useful when you want to set multiple hooks, i.e. one for permissions and second for some special field. -The function receives a ``id_token`` dictionary and ``user`` instance -and returns it with additional fields. +The hook function receives following arguments: + + * ``id_token``: the ID token dictionary which contains at least the + basic claims (``iss``, ``sub``, ``aud``, ``exp``, ``iat``, + ``auth_time``), but may also contain other claims. If several + processing hooks are configured, then the claims of the previous hook + are also present in the passed dictionary. + * ``user``: User object of the authenticating user, + * ``token``: the Token object created for the authentication request, and + * ``request``: Django request object of the authentication request. + +The hook function should return the modified ID token as dictionary. + +.. note:: + It is a good idea to add ``**kwargs`` to the hook function argument + list so that the hook function will work even if new arguments are + added to the hook function call signature. Default is:: - def default_idtoken_processing_hook(id_token, user): + def default_idtoken_processing_hook(id_token, user, token, request, **kwargs): return id_token @@ -103,12 +125,31 @@ Default is:: return str(user.id) -OIDC_IDTOKEN_INCLUDE_CLAIMS -============================== +OIDC_INTROSPECTION_PROCESSING_HOOK +================================== -OPTIONAL. ``bool``. If enabled, id_token will include standard claims of the user (email, first name, etc.). +OPTIONAL. ``str`` or ``(list, tuple)``. -Default is ``False``. +A string with the location of your function hook or ``list`` or ``tuple`` with hook functions. +Here you can add extra dictionary values specific to your valid response value for token introspection. + +The function receives an ``introspection_response`` dictionary, a ``client`` instance and an ``id_token`` dictionary. + +Default is:: + + def default_introspection_processing_hook(introspection_response, client, id_token): + + return introspection_response + + +OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE +========================================== + +OPTIONAL ``bool`` + +A flag which toggles whether the audience is matched against the client resource scope when calling the introspection endpoint. + +Default is ``True``. OIDC_SESSION_MANAGEMENT_ENABLE ============================== diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py new file mode 100644 index 0000000..91b0a5a --- /dev/null +++ b/oidc_provider/lib/endpoints/introspection.py @@ -0,0 +1,98 @@ +import logging + +from django.http import JsonResponse + +from oidc_provider.lib.errors import TokenIntrospectionError +from oidc_provider.lib.utils.common import run_processing_hook +from oidc_provider.lib.utils.oauth2 import extract_client_auth +from oidc_provider.models import Token, Client +from oidc_provider import settings + +logger = logging.getLogger(__name__) + +INTROSPECTION_SCOPE = 'token_introspection' + + +class TokenIntrospectionEndpoint(object): + + def __init__(self, request): + self.request = request + self.params = {} + self.id_token = None + self.client = None + self._extract_params() + + def _extract_params(self): + # Introspection only supports POST requests + self.params['token'] = self.request.POST.get('token') + client_id, client_secret = extract_client_auth(self.request) + self.params['client_id'] = client_id + self.params['client_secret'] = client_secret + + def validate_params(self): + if not (self.params['client_id'] and self.params['client_secret']): + logger.debug('[Introspection] No client credentials provided') + raise TokenIntrospectionError() + if not self.params['token']: + logger.debug('[Introspection] No token provided') + raise TokenIntrospectionError() + try: + token = Token.objects.get(access_token=self.params['token']) + except Token.DoesNotExist: + logger.debug('[Introspection] Token does not exist: %s', self.params['token']) + raise TokenIntrospectionError() + if token.has_expired(): + logger.debug('[Introspection] Token is not valid: %s', self.params['token']) + raise TokenIntrospectionError() + if not token.id_token: + logger.debug('[Introspection] Token not an authentication token: %s', + self.params['token']) + raise TokenIntrospectionError() + + self.id_token = token.id_token + audience = self.id_token.get('aud') + if not audience: + logger.debug('[Introspection] No audience found for token: %s', self.params['token']) + raise TokenIntrospectionError() + + try: + self.client = Client.objects.get( + client_id=self.params['client_id'], + client_secret=self.params['client_secret']) + except Client.DoesNotExist: + logger.debug('[Introspection] No valid client for id: %s', + self.params['client_id']) + raise TokenIntrospectionError() + if INTROSPECTION_SCOPE not in self.client.scope: + logger.debug('[Introspection] Client %s does not have introspection scope', + self.params['client_id']) + raise TokenIntrospectionError() + if settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE') \ + and audience not in self.client.scope: + logger.debug('[Introspection] Client %s does not audience scope %s', + self.params['client_id'], audience) + raise TokenIntrospectionError() + + def create_response_dic(self): + response_dic = dict((k, self.id_token[k]) for k in ('sub', 'exp', 'iat', 'iss')) + response_dic['active'] = True + response_dic['client_id'] = self.id_token.get('aud') + response_dic['aud'] = self.client.client_id + + response_dic = run_processing_hook(response_dic, + 'OIDC_INTROSPECTION_PROCESSING_HOOK', + client=self.client, + id_token=self.id_token) + + return response_dic + + @classmethod + def response(cls, dic, status=200): + """ + Create and return a response object. + """ + response = JsonResponse(dic, status=status) + response['Cache-Control'] = 'no-store' + response['Pragma'] = 'no-cache' + + return response diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 3fbff8e..8c32046 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,7 +1,7 @@ -from base64 import b64decode, urlsafe_b64encode +import inspect +from base64 import urlsafe_b64encode import hashlib import logging -import re from django.contrib.auth import authenticate from django.http import JsonResponse @@ -10,6 +10,7 @@ from oidc_provider.lib.errors import ( TokenError, UserAuthError, ) +from oidc_provider.lib.utils.oauth2 import extract_client_auth from oidc_provider.lib.utils.token import ( create_id_token, create_token, @@ -26,6 +27,7 @@ logger = logging.getLogger(__name__) class TokenEndpoint(object): + def __init__(self, request): self.request = request self.params = {} @@ -33,7 +35,7 @@ class TokenEndpoint(object): self._extract_params() def _extract_params(self): - client_id, client_secret = self._extract_client_auth() + client_id, client_secret = extract_client_auth(self.request) self.params['client_id'] = client_id self.params['client_secret'] = client_secret @@ -49,29 +51,6 @@ class TokenEndpoint(object): self.params['username'] = self.request.POST.get('username', '') self.params['password'] = self.request.POST.get('password', '') - def _extract_client_auth(self): - """ - Get client credentials using HTTP Basic Authentication method. - Or try getting parameters via POST. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - Return a string. - """ - auth_header = self.request.META.get('HTTP_AUTHORIZATION', '') - - if re.compile('^Basic\s{1}.+$').match(auth_header): - b64_user_pass = auth_header.split()[1] - try: - user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') - client_id, client_secret = tuple(user_pass) - except Exception: - client_id = client_secret = '' - else: - client_id = self.request.POST.get('client_id', '') - client_secret = self.request.POST.get('client_secret', '') - - return (client_id, client_secret) - def validate_params(self): try: self.client = Client.objects.get(client_id=self.params['client_id']) @@ -118,7 +97,14 @@ class TokenEndpoint(object): if not settings.get('OIDC_GRANT_TYPE_PASSWORD_ENABLE'): raise TokenError('unsupported_grant_type') + auth_args = (self.request,) + try: + inspect.getcallargs(authenticate, *auth_args) + except TypeError: + auth_args = () + user = authenticate( + *auth_args, username=self.params['username'], password=self.params['password'] ) diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 22d8e9a..318fb96 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -32,6 +32,15 @@ class UserAuthError(Exception): } +class TokenIntrospectionError(Exception): + """ + Specific to the introspection endpoint. This error will be converted + to an "active: false" response, as per the spec. + See https://tools.ietf.org/html/rfc7662 + """ + pass + + class AuthorizeError(Exception): _errors = { diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index ef437e3..1f0626f 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -107,7 +107,8 @@ def default_after_end_session_hook( return None -def default_idtoken_processing_hook(id_token, user): +def default_idtoken_processing_hook( + id_token, user, token, request, **kwargs): """ Hook to perform some additional actions to `id_token` dictionary just before serialization. @@ -117,12 +118,29 @@ def default_idtoken_processing_hook(id_token, user): :param user: user for whom id_token is generated :type user: User + :param token: the Token object created for the authentication request + :type token: oidc_provider.models.Token + + :param request: the request initiating this ID token processing + :type request: django.http.HttpRequest + :return: custom modified dictionary of values for `id_token` - :rtype dict + :rtype: dict """ return id_token +def default_introspection_processing_hook(introspection_response, client, id_token): + """ + Hook to customise the returned data from the token introspection endpoint + :param introspection_response: + :param client: + :param id_token: + :return: + """ + return introspection_response + + def get_browser_state_or_default(request): """ Determine value to use as session state. @@ -130,3 +148,15 @@ def get_browser_state_or_default(request): key = (request.session.session_key or settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY')) return sha224(key.encode('utf-8')).hexdigest() + + +def run_processing_hook(subject, hook_settings_name, **kwargs): + processing_hooks = settings.get(hook_settings_name) + if not isinstance(processing_hooks, (list, tuple)): + processing_hooks = [processing_hooks] + + for hook_string in processing_hooks: + hook = settings.import_from_str(hook_string) + subject = hook(subject, **kwargs) + + return subject diff --git a/oidc_provider/lib/utils/oauth2.py b/oidc_provider/lib/utils/oauth2.py index bfb7849..452325f 100644 --- a/oidc_provider/lib/utils/oauth2.py +++ b/oidc_provider/lib/utils/oauth2.py @@ -1,3 +1,4 @@ +from base64 import b64decode import logging import re @@ -28,6 +29,30 @@ def extract_access_token(request): return access_token +def extract_client_auth(request): + """ + Get client credentials using HTTP Basic Authentication method. + Or try getting parameters via POST. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + Return a tuple `(client_id, client_secret)`. + """ + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + + if re.compile('^Basic\s{1}.+$').match(auth_header): + b64_user_pass = auth_header.split()[1] + try: + user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') + client_id, client_secret = tuple(user_pass) + except Exception: + client_id = client_secret = '' + else: + client_id = request.POST.get('client_id', '') + client_secret = request.POST.get('client_secret', '') + + return (client_id, client_secret) + + def protected_resource_view(scopes=None): """ View decorator. The client accesses protected resources by presenting the diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 2da2c13..d3fd3ab 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -9,7 +9,7 @@ from jwkest.jwk import SYMKey from jwkest.jws import JWS from jwkest.jwt import JWT -from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.common import get_issuer, run_processing_hook from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.models import ( Code, @@ -62,13 +62,9 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope= claims = StandardScopeClaims(token).create_response_dic() dic.update(claims) - processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') - - if isinstance(processing_hook, (list, tuple)): - for hook in processing_hook: - dic = settings.import_from_str(hook)(dic, user=user) - else: - dic = settings.import_from_str(processing_hook)(dic, user=user) + dic = run_processing_hook( + dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', + user=user, token=token, request=request) return dic diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 8c1729d..1fddbfa 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -129,6 +129,22 @@ class DefaultSettings(object): """ return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook' + @property + def OIDC_INTROSPECTION_PROCESSING_HOOK(self): + """ + OPTIONAL. A string with the location of your function. + Used to update the response for a valid introspection token request. + """ + return 'oidc_provider.lib.utils.common.default_introspection_processing_hook' + + @property + def OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE(self): + """ + OPTIONAL: A boolean to specify whether or not to verify that the introspection + resource has the requesting client id as one of its scopes. + """ + return True + @property def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): """ diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 47e09da..0fc33b0 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -1,5 +1,9 @@ import random import string + +import django +from django.contrib.auth.backends import ModelBackend + try: from urlparse import parse_qs, urlsplit except ImportError: @@ -109,7 +113,7 @@ def fake_sub_generator(user): return user.email -def fake_idtoken_processing_hook(id_token, user): +def fake_idtoken_processing_hook(id_token, user, **kwargs): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK. """ @@ -118,7 +122,7 @@ def fake_idtoken_processing_hook(id_token, user): return id_token -def fake_idtoken_processing_hook2(id_token, user): +def fake_idtoken_processing_hook2(id_token, user, **kwargs): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param @@ -126,3 +130,34 @@ def fake_idtoken_processing_hook2(id_token, user): id_token['test_idtoken_processing_hook2'] = FAKE_RANDOM_STRING id_token['test_idtoken_processing_hook_user_email2'] = user.email return id_token + + +def fake_idtoken_processing_hook3(id_token, user, token, **kwargs): + """ + Fake function for checking scope is passed to processing hook. + """ + id_token['scope_of_token_passed_to_processing_hook'] = token.scope + return id_token + + +def fake_idtoken_processing_hook4(id_token, user, **kwargs): + """ + Fake function for checking kwargs passed to processing hook. + """ + id_token['kwargs_passed_to_processing_hook'] = { + key: repr(value) + for (key, value) in kwargs.items() + } + return id_token + + +def fake_introspection_processing_hook(response_dict, client, id_token): + response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING + return response_dict + + +class TestAuthBackend: + def authenticate(self, *args, **kwargs): + if django.VERSION[0] >= 2 or (django.VERSION[0] == 1 and django.VERSION[1] >= 11): + assert len(args) > 0 and args[0] + return ModelBackend().authenticate(*args, **kwargs) diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py new file mode 100644 index 0000000..99eab9a --- /dev/null +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -0,0 +1,116 @@ +import time +import random + +from mock import patch +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode +from django.utils.encoding import force_text +from django.core.management import call_command +from django.test import TestCase, RequestFactory, override_settings +from django.utils import timezone +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse + +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + create_fake_token, + FAKE_RANDOM_STRING) +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.views import TokenIntrospectionView + + +class IntrospectionTestCase(TestCase): + + def setUp(self): + call_command('creatersakey') + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='id_token token') + self.resource = create_fake_client(response_type='id_token token') + self.resource.scope = ['token_introspection', self.client.client_id] + self.resource.save() + self.token = create_fake_token(self.user, self.client.scope, self.client) + self.token.access_token = str(random.randint(1, 999999)).zfill(6) + self.now = time.time() + with patch('oidc_provider.lib.utils.token.time.time') as time_func: + time_func.return_value = self.now + self.token.id_token = create_id_token(self.token, self.user, self.client.client_id) + self.token.save() + + def _assert_inactive(self, response): + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), {'active': False}) + + def _make_request(self, **kwargs): + url = reverse('oidc_provider:token-introspection') + data = { + 'client_id': kwargs.get('client_id', self.resource.client_id), + 'client_secret': kwargs.get('client_secret', self.resource.client_secret), + 'token': kwargs.get('access_token', self.token.access_token), + } + + request = self.factory.post(url, data=urlencode(data), + content_type='application/x-www-form-urlencoded') + + return TokenIntrospectionView.as_view()(request) + + def test_no_client_params_returns_inactive(self): + response = self._make_request(client_id='') + self._assert_inactive(response) + + def test_no_client_secret_returns_inactive(self): + response = self._make_request(client_secret='') + self._assert_inactive(response) + + def test_invalid_client_returns_inactive(self): + response = self._make_request(client_id='invalid') + self._assert_inactive(response) + + def test_token_not_found_returns_inactive(self): + response = self._make_request(access_token='invalid') + self._assert_inactive(response) + + def test_scope_no_audience_returns_inactive(self): + self.resource.scope = ['token_introspection'] + self.resource.save() + response = self._make_request() + self._assert_inactive(response) + + def test_token_expired_returns_inactive(self): + self.token.expires_at = timezone.now() - timezone.timedelta(seconds=60) + self.token.save() + response = self._make_request() + self._assert_inactive(response) + + def test_valid_request_returns_default_properties(self): + response = self._make_request() + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), { + 'active': True, + 'aud': self.resource.client_id, + 'client_id': self.client.client_id, + 'sub': str(self.user.pk), + 'iat': int(self.now), + 'exp': int(self.now + 600), + 'iss': 'http://localhost:8000/openid', + }) + + @override_settings(OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') # NOQA + def test_custom_introspection_hook_called_on_valid_request(self): + response = self._make_request() + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), { + 'active': True, + 'aud': self.resource.client_id, + 'client_id': self.client.client_id, + 'sub': str(self.user.pk), + 'iat': int(self.now), + 'exp': int(self.now + 600), + 'iss': 'http://localhost:8000/openid', + 'test_introspection_processing_hook': FAKE_RANDOM_STRING + }) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 5c787b3..e984df3 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -3,6 +3,7 @@ import time import uuid from base64 import b64encode + try: from urllib.parse import urlencode except ImportError: @@ -256,6 +257,17 @@ class TokenTestCase(TestCase): else: self.assertNotIn(claim, userinfo) + @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, + AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",)) + def test_password_grant_passes_request_to_backend(self): + response = self._post_request( + post_data=self._password_grant_post_data(), + extras=self._password_grant_auth_header() + ) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertIn('access_token', response_dict) + @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code(self): """ @@ -716,6 +728,46 @@ class TokenTestCase(TestCase): self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + @override_settings( + OIDC_IDTOKEN_PROCESSING_HOOK=( + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook3')) + def test_additional_idtoken_processing_hook_scope_available(self): + """ + Test scope is available in OIDC_IDTOKEN_PROCESSING_HOOK. + """ + id_token = self._request_id_token_with_scope( + ['openid', 'email', 'profile', 'dummy']) + self.assertEqual( + id_token.get('scope_of_token_passed_to_processing_hook'), + ['openid', 'email', 'profile', 'dummy']) + + @override_settings( + OIDC_IDTOKEN_PROCESSING_HOOK=( + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook4')) + def test_additional_idtoken_processing_hook_kwargs(self): + """ + Test correct kwargs are passed to OIDC_IDTOKEN_PROCESSING_HOOK. + """ + id_token = self._request_id_token_with_scope(['openid', 'profile']) + kwargs_passed = id_token.get('kwargs_passed_to_processing_hook') + assert kwargs_passed + self.assertEqual(kwargs_passed.get('token'), + '') + self.assertEqual(kwargs_passed.get('request'), + "") + self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'}) + + def _request_id_token_with_scope(self, scope): + code = self._create_code(scope) + + post_data = self._auth_code_post_data(code=code.code) + + response = self._post_request(post_data) + + response_dic = json.loads(response.content.decode('utf-8')) + id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + return id_token + def test_pkce_parameters(self): """ Test Proof Key for Code Exchange by OAuth Public Clients. diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 0ad05b0..44cc914 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ url(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='provider-info'), + url(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 7a19bcc..e6f3ddc 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,5 +1,8 @@ import logging +from django.views.decorators.csrf import csrf_exempt + +from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit @@ -34,7 +37,8 @@ from oidc_provider.lib.errors import ( ClientIdError, RedirectUriError, TokenError, - UserAuthError) + UserAuthError, + TokenIntrospectionError) from oidc_provider.lib.utils.common import ( redirect, get_site_url, @@ -50,6 +54,7 @@ from oidc_provider.models import ( from oidc_provider import settings from oidc_provider import signals + logger = logging.getLogger(__name__) OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') @@ -230,10 +235,10 @@ class TokenView(View): @protected_resource_view(['openid']) def userinfo(request, *args, **kwargs): """ - Create a diccionary with all the requested claims about the End-User. + Create a dictionary with all the requested claims about the End-User. See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - Return a diccionary. + Return a dictionary. """ token = kwargs['token'] @@ -267,6 +272,7 @@ class ProviderInfoView(View): dic['token_endpoint'] = site_url + reverse('oidc_provider:token') dic['userinfo_endpoint'] = site_url + reverse('oidc_provider:userinfo') 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] dic['response_types_supported'] = types_supported @@ -356,3 +362,19 @@ class CheckSessionIframeView(View): def get(self, request, *args, **kwargs): return render(request, 'oidc_provider/check_session_iframe.html', kwargs) + + +class TokenIntrospectionView(View): + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + return super(TokenIntrospectionView, self).dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + introspection = TokenIntrospectionEndpoint(request) + + try: + introspection.validate_params() + dic = introspection.create_response_dic() + return TokenIntrospectionEndpoint.response(dic) + except TokenIntrospectionError: + return TokenIntrospectionEndpoint.response({'active': False}) diff --git a/setup.py b/setup.py index 7d804ad..5152c26 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ setup( author='Juan Ignacio Fiorentino', author_email='juanifioren@gmail.com', classifiers=[ + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', diff --git a/tox.ini b/tox.ini index db00837..4a2ade4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist= - py27-django{17,18,19,110,111}, - py34-django{17,18,19,110,111,20}, + py27-django{18,19,110,111}, + py34-django{18,19,110,111,20}, py35-django{18,19,110,111,20}, py36-django{18,19,110,111,20}, @@ -15,7 +15,6 @@ deps = pytest-django pytest-flake8 pytest-cov - django17: django>=1.7,<1.8 django18: django>=1.8,<1.9 django19: django>=1.9,<1.10 django110: django>=1.10,<1.11 @@ -25,6 +24,16 @@ deps = commands = pytest --flake8 --cov=oidc_provider {posargs} +[testenv:docs] +basepython = python2.7 +changedir = docs +deps = + sphinx + sphinx_rtd_theme +commands = + mkdir -p _static/ + sphinx-build -v -W -b html -d {envtmpdir}/doctrees -D html_static_path="_static" . {envtmpdir}/html + [pytest] DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings python_files = test_*.py