Skip to content

Commit

Permalink
Oidc userinfo (#677)
Browse files Browse the repository at this point in the history
Oidc userinfo
  • Loading branch information
JonathanHuot committed Aug 1, 2019
2 parents 7538f04 + 64e3474 commit f516c16
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 49 deletions.
1 change: 1 addition & 0 deletions docs/oauth2/oidc/validator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------------------------
Expand Down
50 changes: 2 additions & 48 deletions oauthlib/oauth2/rfc6749/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions oauthlib/openid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions oauthlib/openid/connect/core/endpoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
from __future__ import absolute_import, unicode_literals

from .pre_configured import Server
from .userinfo import UserInfoEndpoint
4 changes: 3 additions & 1 deletion oauthlib/openid/connect/core/endpoints/pre_configured.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
102 changes: 102 additions & 0 deletions oauthlib/openid/connect/core/endpoints/userinfo.py
Original file line number Diff line number Diff line change
@@ -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()
42 changes: 42 additions & 0 deletions oauthlib/openid/connect/core/request_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
70 changes: 70 additions & 0 deletions tests/openid/connect/core/endpoints/test_userinfo_endpoint.py
Original file line number Diff line number Diff line change
@@ -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'])

0 comments on commit f516c16

Please sign in to comment.