diff --git a/docs/oauth2/oidc/validator.rst b/docs/oauth2/oidc/validator.rst index 7a6f5744..17f58255 100644 --- a/docs/oauth2/oidc/validator.rst +++ b/docs/oauth2/oidc/validator.rst @@ -20,6 +20,7 @@ Into from oauthlib.openid import RequestValidator Then, you have to implement the new RequestValidator methods as shown below. +Note that a new UserInfo endpoint is defined and need a new controller into your webserver. RequestValidator Extension ---------------------------------------------------- diff --git a/oauthlib/oauth2/rfc6749/__init__.py b/oauthlib/oauth2/rfc6749/__init__.py index aff0ed88..1a4128c5 100644 --- a/oauthlib/oauth2/rfc6749/__init__.py +++ b/oauthlib/oauth2/rfc6749/__init__.py @@ -11,56 +11,10 @@ import functools import logging +from .endpoints.base import BaseEndpoint +from .endpoints.base import catch_errors_and_unavailability from .errors import TemporarilyUnavailableError, ServerError from .errors import FatalClientError, OAuth2Error log = logging.getLogger(__name__) - - -class BaseEndpoint(object): - - def __init__(self): - self._available = True - self._catch_errors = False - - @property - def available(self): - return self._available - - @available.setter - def available(self, available): - self._available = available - - @property - def catch_errors(self): - return self._catch_errors - - @catch_errors.setter - def catch_errors(self, catch_errors): - self._catch_errors = catch_errors - - -def catch_errors_and_unavailability(f): - @functools.wraps(f) - def wrapper(endpoint, uri, *args, **kwargs): - if not endpoint.available: - e = TemporarilyUnavailableError() - log.info('Endpoint unavailable, ignoring request %s.' % uri) - return {}, e.json, 503 - - if endpoint.catch_errors: - try: - return f(endpoint, uri, *args, **kwargs) - except OAuth2Error: - raise - except FatalClientError: - raise - except Exception as e: - error = ServerError() - log.warning( - 'Exception caught while processing request, %s.' % e) - return {}, error.json, 500 - else: - return f(endpoint, uri, *args, **kwargs) - return wrapper diff --git a/oauthlib/openid/__init__.py b/oauthlib/openid/__init__.py index 7f1a8767..8157c297 100644 --- a/oauthlib/openid/__init__.py +++ b/oauthlib/openid/__init__.py @@ -7,4 +7,5 @@ from __future__ import absolute_import, unicode_literals from .connect.core.endpoints import Server +from .connect.core.endpoints import UserInfoEndpoint from .connect.core.request_validator import RequestValidator diff --git a/oauthlib/openid/connect/core/endpoints/__init__.py b/oauthlib/openid/connect/core/endpoints/__init__.py index 719f883c..528841f4 100644 --- a/oauthlib/openid/connect/core/endpoints/__init__.py +++ b/oauthlib/openid/connect/core/endpoints/__init__.py @@ -9,3 +9,4 @@ from __future__ import absolute_import, unicode_literals from .pre_configured import Server +from .userinfo import UserInfoEndpoint diff --git a/oauthlib/openid/connect/core/endpoints/pre_configured.py b/oauthlib/openid/connect/core/endpoints/pre_configured.py index 6367847b..fde2739e 100644 --- a/oauthlib/openid/connect/core/endpoints/pre_configured.py +++ b/oauthlib/openid/connect/core/endpoints/pre_configured.py @@ -34,10 +34,11 @@ AuthorizationTokenGrantDispatcher ) from ..tokens import JWTToken +from .userinfo import UserInfoEndpoint class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): + ResourceEndpoint, RevocationEndpoint, UserInfoEndpoint): """An all-in-one endpoint featuring all four major grant types.""" @@ -105,3 +106,4 @@ def __init__(self, request_validator, token_expires_in=None, token_types={'Bearer': bearer, 'JWT': jwt}) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) + UserInfoEndpoint.__init__(self, request_validator) diff --git a/oauthlib/openid/connect/core/endpoints/userinfo.py b/oauthlib/openid/connect/core/endpoints/userinfo.py new file mode 100644 index 00000000..7a39f76b --- /dev/null +++ b/oauthlib/openid/connect/core/endpoints/userinfo.py @@ -0,0 +1,102 @@ +""" +oauthlib.openid.connect.core.endpoints.userinfo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of userinfo endpoint. +""" +from __future__ import absolute_import, unicode_literals + +import json +import logging + +from oauthlib.common import Request +from oauthlib.common import unicode_type +from oauthlib.oauth2.rfc6749.endpoints.base import BaseEndpoint +from oauthlib.oauth2.rfc6749.endpoints.base import catch_errors_and_unavailability +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.oauth2.rfc6749 import errors + + +log = logging.getLogger(__name__) + + +class UserInfoEndpoint(BaseEndpoint): + """Authorizes access to userinfo resource. + """ + def __init__(self, request_validator): + self.bearer = BearerToken(request_validator, None, None, None) + self.request_validator = request_validator + BaseEndpoint.__init__(self) + + @catch_errors_and_unavailability + def create_userinfo_response(self, uri, http_method='GET', body=None, headers=None): + """Validate BearerToken and return userinfo from RequestValidator + + The UserInfo Endpoint MUST return a + content-type header to indicate which format is being returned. The + content-type of the HTTP response MUST be application/json if the + response body is a text JSON object; the response body SHOULD be encoded + using UTF-8. + """ + request = Request(uri, http_method, body, headers) + request.scopes = ["openid"] + self.validate_userinfo_request(request) + + claims = self.request_validator.get_userinfo_claims(request) + if claims is None: + log.error('Userinfo MUST have claims for %r.', request) + raise errors.ServerError(status_code=500) + + if isinstance(claims, dict): + resp_headers = { + 'Content-Type': 'application/json' + } + if "sub" not in claims: + log.error('Userinfo MUST have "sub" for %r.', request) + raise errors.ServerError(status_code=500) + body = json.dumps(claims) + elif isinstance(claims, unicode_type): + resp_headers = { + 'Content-Type': 'application/jwt' + } + body = claims + else: + log.error('Userinfo return unknown response for %r.', request) + raise errors.ServerError(status_code=500) + log.debug('Userinfo access valid for %r.', request) + return resp_headers, body, 200 + + def validate_userinfo_request(self, request): + """Ensure the request is valid. + + 5.3.1. UserInfo Request + The Client sends the UserInfo Request using either HTTP GET or HTTP + POST. The Access Token obtained from an OpenID Connect Authentication + Request MUST be sent as a Bearer Token, per Section 2 of OAuth 2.0 + Bearer Token Usage [RFC6750]. + + It is RECOMMENDED that the request use the HTTP GET method and the + Access Token be sent using the Authorization header field. + + The following is a non-normative example of a UserInfo Request: + + GET /userinfo HTTP/1.1 + Host: server.example.com + Authorization: Bearer SlAV32hkKG + + 5.3.3. UserInfo Error Response + When an error condition occurs, the UserInfo Endpoint returns an Error + Response as defined in Section 3 of OAuth 2.0 Bearer Token Usage + [RFC6750]. (HTTP errors unrelated to RFC 6750 are returned to the User + Agent using the appropriate HTTP status code.) + + The following is a non-normative example of a UserInfo Error Response: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token", + error_description="The Access Token expired" + """ + if not self.bearer.validate_request(request): + raise errors.InvalidTokenError() + if "openid" not in request.scopes: + raise errors.InsufficientScopeError() diff --git a/oauthlib/openid/connect/core/request_validator.py b/oauthlib/openid/connect/core/request_validator.py index d96c9efd..e853d39c 100644 --- a/oauthlib/openid/connect/core/request_validator.py +++ b/oauthlib/openid/connect/core/request_validator.py @@ -265,3 +265,45 @@ def validate_user_match(self, id_token_hint, scopes, claims, request): - OpenIDConnectHybrid """ raise NotImplementedError('Subclasses must implement this method.') + + def get_userinfo_claims(self, request): + """Return the UserInfo claims in JSON or Signed or Encrypted. + + The UserInfo Claims MUST be returned as the members of a JSON object + unless a signed or encrypted response was requested during Client + Registration. The Claims defined in Section 5.1 can be returned, as can + additional Claims not specified there. + + For privacy reasons, OpenID Providers MAY elect to not return values for + some requested Claims. + + If a Claim is not returned, that Claim Name SHOULD be omitted from the + JSON object representing the Claims; it SHOULD NOT be present with a + null or empty string value. + + The sub (subject) Claim MUST always be returned in the UserInfo + Response. + + Upon receipt of the UserInfo Request, the UserInfo Endpoint MUST return + the JSON Serialization of the UserInfo Response as in Section 13.3 in + the HTTP response body unless a different format was specified during + Registration [OpenID.Registration]. + + If the UserInfo Response is signed and/or encrypted, then the Claims are + returned in a JWT and the content-type MUST be application/jwt. The + response MAY be encrypted without also being signed. If both signing and + encryption are requested, the response MUST be signed then encrypted, + with the result being a Nested JWT, as defined in [JWT]. + + If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) + and aud (audience) as members. The iss value SHOULD be the OP's Issuer + Identifier URL. The aud value SHOULD be or include the RP's Client ID + value. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: Claims as a dict OR JWT/JWS/JWE as a string + + Method is used by: + UserInfoEndpoint + """ diff --git a/tests/openid/connect/core/endpoints/test_userinfo_endpoint.py b/tests/openid/connect/core/endpoints/test_userinfo_endpoint.py new file mode 100644 index 00000000..4593d790 --- /dev/null +++ b/tests/openid/connect/core/endpoints/test_userinfo_endpoint.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import mock +import json + +from oauthlib.openid import RequestValidator +from oauthlib.openid import UserInfoEndpoint +from oauthlib.oauth2.rfc6749 import errors + +from tests.unittest import TestCase + + +def set_scopes_valid(token, scopes, request): + request.scopes = ["openid", "bar"] + return True + + +class UserInfoEndpointTest(TestCase): + def setUp(self): + self.claims = { + "sub": "john", + "fruit": "banana" + } + # Can't use MagicMock/wraps below. + # Triggers error when endpoint copies to self.bearer.request_validator + self.validator = RequestValidator() + self.validator.validate_bearer_token = mock.Mock() + self.validator.validate_bearer_token.side_effect = set_scopes_valid + self.validator.get_userinfo_claims = mock.Mock() + self.validator.get_userinfo_claims.return_value = self.claims + self.endpoint = UserInfoEndpoint(self.validator) + + self.uri = 'should_not_matter' + self.headers = { + 'Authorization': 'Bearer eyJxx' + } + + def test_userinfo_no_auth(self): + self.endpoint.create_userinfo_response(self.uri) + + def test_userinfo_wrong_auth(self): + self.headers['Authorization'] = 'Basic foifoifoi' + self.endpoint.create_userinfo_response(self.uri, headers=self.headers) + + def test_userinfo_token_expired(self): + self.validator.validate_bearer_token.return_value = False + self.endpoint.create_userinfo_response(self.uri, headers=self.headers) + + def test_userinfo_token_no_openid_scope(self): + def set_scopes_invalid(token, scopes, request): + request.scopes = ["foo", "bar"] + return True + self.validator.validate_bearer_token.side_effect = set_scopes_invalid + with self.assertRaises(errors.InsufficientScopeError) as context: + self.endpoint.create_userinfo_response(self.uri) + + def test_userinfo_json_response(self): + h, b, s = self.endpoint.create_userinfo_response(self.uri) + self.assertEqual(s, 200) + body_json = json.loads(b) + self.assertEqual(self.claims, body_json) + self.assertEqual("application/json", h['Content-Type']) + + def test_userinfo_jwt_response(self): + self.validator.get_userinfo_claims.return_value = "eyJzzzzz" + h, b, s = self.endpoint.create_userinfo_response(self.uri) + self.assertEqual(s, 200) + self.assertEqual(b, "eyJzzzzz") + self.assertEqual("application/jwt", h['Content-Type'])
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: