From 9b95e4e8f094d78abe577203ad1ef53aecfdb270 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Wed, 8 Nov 2017 09:55:03 +0100 Subject: Added initial introspect support --- docs/feature_matrix.rst | 1 + docs/oauth2/endpoints/endpoints.rst | 6 +- docs/oauth2/endpoints/introspect.rst | 26 ++++ oauthlib/oauth2/__init__.py | 1 + oauthlib/oauth2/rfc6749/endpoints/__init__.py | 1 + oauthlib/oauth2/rfc6749/endpoints/introspect.py | 135 +++++++++++++++++++++ .../oauth2/rfc6749/endpoints/pre_configured.py | 27 +++-- oauthlib/oauth2/rfc6749/errors.py | 2 +- oauthlib/oauth2/rfc6749/request_validator.py | 20 +++ .../rfc6749/endpoints/test_introspect_endpoint.py | 132 ++++++++++++++++++++ 10 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 docs/oauth2/endpoints/introspect.rst create mode 100644 oauthlib/oauth2/rfc6749/endpoints/introspect.py create mode 100644 tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py diff --git a/docs/feature_matrix.rst b/docs/feature_matrix.rst index 0f9021d..59f3f3a 100644 --- a/docs/feature_matrix.rst +++ b/docs/feature_matrix.rst @@ -17,6 +17,7 @@ OAuth 2 client and provider support for - Bearer Tokens - Draft MAC tokens - Token Revocation +- Token Introspection - OpenID Connect Authentication with support for SAML2 and JWT tokens, dynamic client registration and more to diff --git a/docs/oauth2/endpoints/endpoints.rst b/docs/oauth2/endpoints/endpoints.rst index 0e70798..5f7ae8c 100644 --- a/docs/oauth2/endpoints/endpoints.rst +++ b/docs/oauth2/endpoints/endpoints.rst @@ -14,11 +14,12 @@ client attempts to access the user resources on their behalf. :maxdepth: 2 authorization + introspect token resource revocation -There are three different endpoints, the authorization endpoint which mainly +There are three main endpoints, the authorization endpoint which mainly handles user authorization, the token endpoint which provides tokens and the resource endpoint which provides access to protected resources. It is to the endpoints you will feed requests and get back an almost complete response. This @@ -27,3 +28,6 @@ later. The main purpose of the endpoint in OAuthLib is to figure out which grant type or token to dispatch the request to. + +Then, you can extend your OAuth implementation by proposing introspect or +revocation endpoints. diff --git a/docs/oauth2/endpoints/introspect.rst b/docs/oauth2/endpoints/introspect.rst new file mode 100644 index 0000000..53ade8b --- /dev/null +++ b/docs/oauth2/endpoints/introspect.rst @@ -0,0 +1,26 @@ +=================== +Token introspection +=================== + +Introspect endpoints read opaque access and/or refresh tokens upon client +request. Also known as tokeninfo. + +.. code-block:: python + + # Initial setup + from your_validator import your_validator + server = WebApplicationServer(your_validator) + + # Token revocation + uri = 'https://example.com/introspect' + headers, body, http_method = {}, 'token=sldafh309sdf', 'POST' + + headers, body, status = server.create_introspect_response(uri, + headers=headers, body=body, http_method=http_method) + + from your_framework import http_response + http_response(body, status=status, headers=headers) + + +.. autoclass:: oauthlib.oauth2.IntrospectEndpoint + :members: diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index c8d934e..dc7b431 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -15,6 +15,7 @@ from .rfc6749.clients import LegacyApplicationClient from .rfc6749.clients import BackendApplicationClient from .rfc6749.clients import ServiceApplicationClient from .rfc6749.endpoints import AuthorizationEndpoint +from .rfc6749.endpoints import IntrospectEndpoint from .rfc6749.endpoints import TokenEndpoint from .rfc6749.endpoints import ResourceEndpoint from .rfc6749.endpoints import RevocationEndpoint diff --git a/oauthlib/oauth2/rfc6749/endpoints/__init__.py b/oauthlib/oauth2/rfc6749/endpoints/__init__.py index 848bec6..9557f92 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/__init__.py +++ b/oauthlib/oauth2/rfc6749/endpoints/__init__.py @@ -9,6 +9,7 @@ for consuming and providing OAuth 2.0 RFC6749. from __future__ import absolute_import, unicode_literals from .authorization import AuthorizationEndpoint +from .introspect import IntrospectEndpoint from .token import TokenEndpoint from .resource import ResourceEndpoint from .revocation import RevocationEndpoint diff --git a/oauthlib/oauth2/rfc6749/endpoints/introspect.py b/oauthlib/oauth2/rfc6749/endpoints/introspect.py new file mode 100644 index 0000000..7613acc --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/introspect.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.endpoint.introspect +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An implementation of the OAuth 2.0 `Token Introspection`. + +.. _`Token Introspection`: https://tools.ietf.org/html/rfc7662 +""" +from __future__ import absolute_import, unicode_literals + +import json +import logging + +from oauthlib.common import Request + +from ..errors import (InvalidClientError, InvalidRequestError, OAuth2Error, + UnsupportedTokenTypeError) +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class IntrospectEndpoint(BaseEndpoint): + + """Introspect token endpoint. + + This endpoint defines a method to query an OAuth 2.0 authorization + server to determine the active state of an OAuth 2.0 token and to + determine meta-information about this token. OAuth 2.0 deployments + can use this method to convey information about the authorization + context of the token from the authorization server to the protected + resource. + + To prevent the values of access tokens from leaking into + server-side logs via query parameters, an authorization server + offering token introspection MAY disallow the use of HTTP GET on + the introspection endpoint and instead require the HTTP POST method + to be used at the introspection endpoint. + """ + + valid_token_types = ('access_token', 'refresh_token') + + def __init__(self, request_validator, supported_token_types=None): + BaseEndpoint.__init__(self) + self.request_validator = request_validator + self.supported_token_types = ( + supported_token_types or self.valid_token_types) + + @catch_errors_and_unavailability + def create_introspect_response(self, uri, http_method='POST', body=None, + headers=None): + """Create introspect valid or invalid response + + If the authorization server is unable to determine the state + of the token without additional information, it SHOULD return + an introspection response indicating the token is not active + as described in Section 2.2. + """ + request = Request(uri, http_method, body, headers) + try: + self.validate_introspect_request(request) + log.debug('Token introspect valid for %r.', request) + except OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + return {}, e.json, e.status_code + + claims = self.request_validator.introspect_token( + request.token, + request.token_type_hint, + request + ) + headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + if claims is None: + return headers, json.dumps(dict(active=False)), 200 + if "active" in claims: + claims.pop("active") + return headers, json.dumps(dict(active=True, **claims)), 200 + + def validate_introspect_request(self, request): + """Ensure the request is valid. + + The protected resource calls the introspection endpoint using + an HTTP POST request with parameters sent as + "application/x-www-form-urlencoded". + + token REQUIRED. The string value of the token. + + token_type_hint OPTIONAL. + A hint about the type of the token submitted for + introspection. The protected resource MAY pass this parameter to + help the authorization server optimize the token lookup. If the + server is unable to locate the token using the given hint, it MUST + extend its search across all of its supported token types. An + authorization server MAY ignore this parameter, particularly if it + is able to detect the token type automatically. + * access_token: An Access Token as defined in [`RFC6749`], + `section 1.4`_ + + * refresh_token: A Refresh Token as defined in [`RFC6749`], + `section 1.5`_ + + The introspection endpoint MAY accept other OPTIONAL + parameters to provide further context to the query. For + instance, an authorization server may desire to know the IP + address of the client accessing the protected resource to + determine if the correct client is likely to be presenting the + token. The definition of this or any other parameters are + outside the scope of this specification, to be defined by + service documentation or extensions to this specification. + + .. _`section 1.4`: http://tools.ietf.org/html/rfc6749#section-1.4 + .. _`section 1.5`: http://tools.ietf.org/html/rfc6749#section-1.5 + .. _`RFC6749`: http://tools.ietf.org/html/rfc6749 + """ + if not request.token: + raise InvalidRequestError(request=request, + description='Missing token parameter.') + + if self.request_validator.client_authentication_required(request): + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + log.debug('Client authentication failed, %r.', request) + raise InvalidClientError(request=request) + + if (request.token_type_hint and + request.token_type_hint in self.valid_token_types and + request.token_type_hint not in self.supported_token_types): + raise UnsupportedTokenTypeError(request=request) diff --git a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py index 07c3715..f1dfead 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py +++ b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -18,13 +18,14 @@ from ..grant_types import (AuthCodeGrantDispatcher, AuthorizationCodeGrant, ResourceOwnerPasswordCredentialsGrant) from ..tokens import BearerToken from .authorization import AuthorizationEndpoint +from .introspect import IntrospectEndpoint from .resource import ResourceEndpoint from .revocation import RevocationEndpoint from .token import TokenEndpoint -class Server(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, - RevocationEndpoint): +class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, + ResourceEndpoint, RevocationEndpoint): """An all-in-one endpoint featuring all four major grant types.""" @@ -88,10 +89,11 @@ class Server(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, ResourceEndpoint.__init__(self, default_token='Bearer', token_types={'Bearer': bearer}) RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) -class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, - RevocationEndpoint): +class WebApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, + ResourceEndpoint, RevocationEndpoint): """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" @@ -126,10 +128,11 @@ class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoin ResourceEndpoint.__init__(self, default_token='Bearer', token_types={'Bearer': bearer}) RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) -class MobileApplicationServer(AuthorizationEndpoint, ResourceEndpoint, - RevocationEndpoint): +class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, + ResourceEndpoint, RevocationEndpoint): """An all-in-one endpoint featuring Implicit code grant and Bearer tokens.""" @@ -159,10 +162,11 @@ class MobileApplicationServer(AuthorizationEndpoint, ResourceEndpoint, token_types={'Bearer': bearer}) RevocationEndpoint.__init__(self, request_validator, supported_token_types=['access_token']) + IntrospectEndpoint.__init__(self, request_validator) -class LegacyApplicationServer(TokenEndpoint, ResourceEndpoint, - RevocationEndpoint): +class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, + ResourceEndpoint, RevocationEndpoint): """An all-in-one endpoint featuring Resource Owner Password Credentials grant and Bearer tokens.""" @@ -195,10 +199,11 @@ class LegacyApplicationServer(TokenEndpoint, ResourceEndpoint, ResourceEndpoint.__init__(self, default_token='Bearer', token_types={'Bearer': bearer}) RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) -class BackendApplicationServer(TokenEndpoint, ResourceEndpoint, - RevocationEndpoint): +class BackendApplicationServer(TokenEndpoint, IntrospectEndpoint, + ResourceEndpoint, RevocationEndpoint): """An all-in-one endpoint featuring Client Credentials grant and Bearer tokens.""" @@ -228,3 +233,5 @@ class BackendApplicationServer(TokenEndpoint, ResourceEndpoint, token_types={'Bearer': bearer}) RevocationEndpoint.__init__(self, request_validator, supported_token_types=['access_token']) + IntrospectEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) diff --git a/oauthlib/oauth2/rfc6749/errors.py b/oauthlib/oauth2/rfc6749/errors.py index 180f636..1d5e98d 100644 --- a/oauthlib/oauth2/rfc6749/errors.py +++ b/oauthlib/oauth2/rfc6749/errors.py @@ -267,7 +267,7 @@ class UnsupportedGrantTypeError(OAuth2Error): class UnsupportedTokenTypeError(OAuth2Error): """ - The authorization server does not support the revocation of the + The authorization server does not support the hint of the presented token type. I.e. the client tried to revoke an access token on a server not supporting this feature. """ diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py index ba129d5..525ba33 100644 --- a/oauthlib/oauth2/rfc6749/request_validator.py +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -166,6 +166,26 @@ class RequestValidator(object): """ return False + def introspect_token(self, token, token_type_hint, request, *args, **kwargs): + """Introspect an access or refresh token. + + Called once introspect token request is validated. This method + should return a dictionary with any desired claims associated + with the *token*. The implementation can use *token_type_hint* + to lookup this type first, but then it must fallback to other + types known, to be compliant with RFC. + + The dict of claims is added to request.token after this method. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: The HTTP Request (oauthlib.common.Request) + + Method is used by: + - Introspect Endpoint (all grants are compatible) + """ + raise NotImplementedError('Subclasses must implement this method.') + def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): """Invalidate an authorization code after use. diff --git a/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py b/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py new file mode 100644 index 0000000..7ec8190 --- /dev/null +++ b/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from json import loads + +from mock import MagicMock + +from oauthlib.common import urlencode +from oauthlib.oauth2 import RequestValidator, IntrospectEndpoint + +from ....unittest import TestCase + + +class IntrospectEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.client_authentication_required.return_value = True + self.validator.authenticate_client.return_value = True + self.validator.validate_bearer_token.return_value = True + self.validator.introspect_token.return_value = {} + self.endpoint = IntrospectEndpoint(self.validator) + + self.uri = 'should_not_matter' + self.headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + self.resp_h = { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + 'Pragma': 'no-cache' + } + self.resp_b = { + "active": True + } + + def test_introspect_token(self): + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type)]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), self.resp_b) + self.assertEqual(s, 200) + + def test_introspect_token_nohint(self): + # don't specify token_type_hint + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), self.resp_b) + self.assertEqual(s, 200) + + def test_introspect_token_false(self): + self.validator.introspect_token.return_value = None + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), {"active": False}) + self.assertEqual(s, 200) + + def test_introspect_token_claims(self): + self.validator.introspect_token.return_value = {"foo": "bar"} + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), {"active": True, "foo": "bar"}) + self.assertEqual(s, 200) + + def test_introspect_token_claims_spoof_active(self): + self.validator.introspect_token.return_value = {"foo": "bar", "active": False} + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), {"active": True, "foo": "bar"}) + self.assertEqual(s, 200) + + def test_introspect_token_client_authentication_failed(self): + self.validator.authenticate_client.return_value = False + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(loads(b)['error'], 'invalid_client') + self.assertEqual(s, 401) + + def test_introspect_token_public_client_authentication(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = True + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type)]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), self.resp_b) + self.assertEqual(s, 200) + + def test_introspect_token_public_client_authentication_failed(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = False + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(loads(b)['error'], 'invalid_client') + self.assertEqual(s, 401) + + + def test_introspect_unsupported_token(self): + endpoint = IntrospectEndpoint(self.validator, + supported_token_types=['access_token']) + body = urlencode([('token', 'foo'), + ('token_type_hint', 'refresh_token')]) + h, b, s = endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(loads(b)['error'], 'unsupported_token_type') + self.assertEqual(s, 400) + + h, b, s = endpoint.create_introspect_response(self.uri, + headers=self.headers, body='') + self.assertEqual(h, {}) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertEqual(s, 400) -- cgit v1.2.1 From ef8a3b47305b23b278310c1f21106c677a748434 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Tue, 19 Dec 2017 15:18:48 +0100 Subject: Added default supported_token_types for Mobile --- oauthlib/oauth2/rfc6749/endpoints/pre_configured.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py index f1dfead..378339a 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py +++ b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -162,7 +162,8 @@ class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, token_types={'Bearer': bearer}) RevocationEndpoint.__init__(self, request_validator, supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, -- cgit v1.2.1 From 296c6bc5931c95f631c1a496dacc523959fc50e9 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Tue, 19 Dec 2017 15:19:09 +0100 Subject: Improved doc by adding links to RFC and list of claims. --- oauthlib/oauth2/rfc6749/request_validator.py | 30 +++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py index 525ba33..4b76b7a 100644 --- a/oauthlib/oauth2/rfc6749/request_validator.py +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -169,11 +169,28 @@ class RequestValidator(object): def introspect_token(self, token, token_type_hint, request, *args, **kwargs): """Introspect an access or refresh token. - Called once introspect token request is validated. This method - should return a dictionary with any desired claims associated - with the *token*. The implementation can use *token_type_hint* - to lookup this type first, but then it must fallback to other - types known, to be compliant with RFC. + Called once the introspect request is validated. This method should + verify the *token* and either return a dictionary with the list of + claims associated, or `None` in case the token is unknown. + + Below the list of registered claims you should be interested in: + - scope : space-separated list of scopes + - client_id : client identifier + - username : human-readable identifier for the resource owner + - token_type : type of the token + - exp : integer timestamp indicating when this token will expire + - iat : integer timestamp indicating when this token was issued + - nbf : integer timestamp indicating when it can be "not-before" used + - sub : subject of the token - identifier of the resource owner + - aud : list of string identifiers representing the intended audience + - iss : string representing issuer of this token + - jti : string identifier for the token + + Note that most of them are coming directly from JWT RFC. More details + can be found in `Introspect Claims`_ or `_JWT Claims`_. + + The implementation can use *token_type_hint* to improve lookup + efficency, but must fallback to other types to be compliant with RFC. The dict of claims is added to request.token after this method. @@ -183,6 +200,9 @@ class RequestValidator(object): Method is used by: - Introspect Endpoint (all grants are compatible) + + .. _`Introspect Claims`: https://tools.ietf.org/html/rfc7662#section-2.2 + .. _`JWT Claims`: https://tools.ietf.org/html/rfc7519#section-4 """ raise NotImplementedError('Subclasses must implement this method.') -- cgit v1.2.1