Merge pull request #231 from mdaniline/develop
Add token introspection endpoint
This commit is contained in:
commit
1ba8c5c6de
10 changed files with 320 additions and 11 deletions
|
@ -90,6 +90,33 @@ Default is::
|
|||
|
||||
return id_token
|
||||
|
||||
OIDC_INTROSPECTION_PROCESSING_HOOK
|
||||
==================================
|
||||
|
||||
OPTIONAL. ``str`` or ``(list, tuple)``.
|
||||
|
||||
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_IDTOKEN_SUB_GENERATOR
|
||||
==========================
|
||||
|
||||
|
|
98
oidc_provider/lib/endpoints/introspection.py
Normal file
98
oidc_provider/lib/endpoints/introspection.py
Normal file
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
@ -123,6 +123,17 @@ def default_idtoken_processing_hook(id_token, user):
|
|||
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 +141,13 @@ 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_hook = settings.get(hook_settings_name)
|
||||
if isinstance(processing_hook, (list, tuple)):
|
||||
for hook in processing_hook:
|
||||
subject = settings.import_from_str(hook)(subject, **kwargs)
|
||||
else:
|
||||
subject = settings.import_from_str(processing_hook)(subject, **kwargs)
|
||||
return subject
|
||||
|
|
|
@ -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,7 @@ 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)
|
||||
|
||||
return dic
|
||||
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -126,3 +126,8 @@ 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_introspection_processing_hook(response_dict, client, id_token):
|
||||
response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING
|
||||
return response_dict
|
||||
|
|
116
oidc_provider/tests/cases/test_introspection_endpoint.py
Normal file
116
oidc_provider/tests/cases/test_introspection_endpoint.py
Normal file
|
@ -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
|
||||
})
|
|
@ -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'),
|
||||
]
|
||||
|
||||
|
|
|
@ -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})
|
||||
|
|
Loading…
Reference in a new issue