Skip to content

Commit f516c16

Browse files
authored
Oidc userinfo (#677)
Oidc userinfo
2 parents 7538f04 + 64e3474 commit f516c16

File tree

8 files changed

+222
-49
lines changed

8 files changed

+222
-49
lines changed

docs/oauth2/oidc/validator.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Into
2020
from oauthlib.openid import RequestValidator
2121
2222
Then, you have to implement the new RequestValidator methods as shown below.
23+
Note that a new UserInfo endpoint is defined and need a new controller into your webserver.
2324

2425
RequestValidator Extension
2526
----------------------------------------------------

oauthlib/oauth2/rfc6749/__init__.py

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,56 +11,10 @@
1111
import functools
1212
import logging
1313

14+
from .endpoints.base import BaseEndpoint
15+
from .endpoints.base import catch_errors_and_unavailability
1416
from .errors import TemporarilyUnavailableError, ServerError
1517
from .errors import FatalClientError, OAuth2Error
1618

1719

1820
log = logging.getLogger(__name__)
19-
20-
21-
class BaseEndpoint(object):
22-
23-
def __init__(self):
24-
self._available = True
25-
self._catch_errors = False
26-
27-
@property
28-
def available(self):
29-
return self._available
30-
31-
@available.setter
32-
def available(self, available):
33-
self._available = available
34-
35-
@property
36-
def catch_errors(self):
37-
return self._catch_errors
38-
39-
@catch_errors.setter
40-
def catch_errors(self, catch_errors):
41-
self._catch_errors = catch_errors
42-
43-
44-
def catch_errors_and_unavailability(f):
45-
@functools.wraps(f)
46-
def wrapper(endpoint, uri, *args, **kwargs):
47-
if not endpoint.available:
48-
e = TemporarilyUnavailableError()
49-
log.info('Endpoint unavailable, ignoring request %s.' % uri)
50-
return {}, e.json, 503
51-
52-
if endpoint.catch_errors:
53-
try:
54-
return f(endpoint, uri, *args, **kwargs)
55-
except OAuth2Error:
56-
raise
57-
except FatalClientError:
58-
raise
59-
except Exception as e:
60-
error = ServerError()
61-
log.warning(
62-
'Exception caught while processing request, %s.' % e)
63-
return {}, error.json, 500
64-
else:
65-
return f(endpoint, uri, *args, **kwargs)
66-
return wrapper

oauthlib/openid/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
from __future__ import absolute_import, unicode_literals
88

99
from .connect.core.endpoints import Server
10+
from .connect.core.endpoints import UserInfoEndpoint
1011
from .connect.core.request_validator import RequestValidator

oauthlib/openid/connect/core/endpoints/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
from __future__ import absolute_import, unicode_literals
1010

1111
from .pre_configured import Server
12+
from .userinfo import UserInfoEndpoint

oauthlib/openid/connect/core/endpoints/pre_configured.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@
3434
AuthorizationTokenGrantDispatcher
3535
)
3636
from ..tokens import JWTToken
37+
from .userinfo import UserInfoEndpoint
3738

3839

3940
class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint,
40-
ResourceEndpoint, RevocationEndpoint):
41+
ResourceEndpoint, RevocationEndpoint, UserInfoEndpoint):
4142

4243
"""An all-in-one endpoint featuring all four major grant types."""
4344

@@ -105,3 +106,4 @@ def __init__(self, request_validator, token_expires_in=None,
105106
token_types={'Bearer': bearer, 'JWT': jwt})
106107
RevocationEndpoint.__init__(self, request_validator)
107108
IntrospectEndpoint.__init__(self, request_validator)
109+
UserInfoEndpoint.__init__(self, request_validator)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
oauthlib.openid.connect.core.endpoints.userinfo
3+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4+
5+
This module is an implementation of userinfo endpoint.
6+
"""
7+
from __future__ import absolute_import, unicode_literals
8+
9+
import json
10+
import logging
11+
12+
from oauthlib.common import Request
13+
from oauthlib.common import unicode_type
14+
from oauthlib.oauth2.rfc6749.endpoints.base import BaseEndpoint
15+
from oauthlib.oauth2.rfc6749.endpoints.base import catch_errors_and_unavailability
16+
from oauthlib.oauth2.rfc6749.tokens import BearerToken
17+
from oauthlib.oauth2.rfc6749 import errors
18+
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
class UserInfoEndpoint(BaseEndpoint):
24+
"""Authorizes access to userinfo resource.
25+
"""
26+
def __init__(self, request_validator):
27+
self.bearer = BearerToken(request_validator, None, None, None)
28+
self.request_validator = request_validator
29+
BaseEndpoint.__init__(self)
30+
31+
@catch_errors_and_unavailability
32+
def create_userinfo_response(self, uri, http_method='GET', body=None, headers=None):
33+
"""Validate BearerToken and return userinfo from RequestValidator
34+
35+
The UserInfo Endpoint MUST return a
36+
content-type header to indicate which format is being returned. The
37+
content-type of the HTTP response MUST be application/json if the
38+
response body is a text JSON object; the response body SHOULD be encoded
39+
using UTF-8.
40+
"""
41+
request = Request(uri, http_method, body, headers)
42+
request.scopes = ["openid"]
43+
self.validate_userinfo_request(request)
44+
45+
claims = self.request_validator.get_userinfo_claims(request)
46+
if claims is None:
47+
log.error('Userinfo MUST have claims for %r.', request)
48+
raise errors.ServerError(status_code=500)
49+
50+
if isinstance(claims, dict):
51+
resp_headers = {
52+
'Content-Type': 'application/json'
53+
}
54+
if "sub" not in claims:
55+
log.error('Userinfo MUST have "sub" for %r.', request)
56+
raise errors.ServerError(status_code=500)
57+
body = json.dumps(claims)
58+
elif isinstance(claims, unicode_type):
59+
resp_headers = {
60+
'Content-Type': 'application/jwt'
61+
}
62+
body = claims
63+
else:
64+
log.error('Userinfo return unknown response for %r.', request)
65+
raise errors.ServerError(status_code=500)
66+
log.debug('Userinfo access valid for %r.', request)
67+
return resp_headers, body, 200
68+
69+
def validate_userinfo_request(self, request):
70+
"""Ensure the request is valid.
71+
72+
5.3.1. UserInfo Request
73+
The Client sends the UserInfo Request using either HTTP GET or HTTP
74+
POST. The Access Token obtained from an OpenID Connect Authentication
75+
Request MUST be sent as a Bearer Token, per Section 2 of OAuth 2.0
76+
Bearer Token Usage [RFC6750].
77+
78+
It is RECOMMENDED that the request use the HTTP GET method and the
79+
Access Token be sent using the Authorization header field.
80+
81+
The following is a non-normative example of a UserInfo Request:
82+
83+
GET /userinfo HTTP/1.1
84+
Host: server.example.com
85+
Authorization: Bearer SlAV32hkKG
86+
87+
5.3.3. UserInfo Error Response
88+
When an error condition occurs, the UserInfo Endpoint returns an Error
89+
Response as defined in Section 3 of OAuth 2.0 Bearer Token Usage
90+
[RFC6750]. (HTTP errors unrelated to RFC 6750 are returned to the User
91+
Agent using the appropriate HTTP status code.)
92+
93+
The following is a non-normative example of a UserInfo Error Response:
94+
95+
HTTP/1.1 401 Unauthorized
96+
WWW-Authenticate: Bearer error="invalid_token",
97+
error_description="The Access Token expired"
98+
"""
99+
if not self.bearer.validate_request(request):
100+
raise errors.InvalidTokenError()
101+
if "openid" not in request.scopes:
102+
raise errors.InsufficientScopeError()

oauthlib/openid/connect/core/request_validator.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,45 @@ def validate_user_match(self, id_token_hint, scopes, claims, request):
265265
- OpenIDConnectHybrid
266266
"""
267267
raise NotImplementedError('Subclasses must implement this method.')
268+
269+
def get_userinfo_claims(self, request):
270+
"""Return the UserInfo claims in JSON or Signed or Encrypted.
271+
272+
The UserInfo Claims MUST be returned as the members of a JSON object
273+
unless a signed or encrypted response was requested during Client
274+
Registration. The Claims defined in Section 5.1 can be returned, as can
275+
additional Claims not specified there.
276+
277+
For privacy reasons, OpenID Providers MAY elect to not return values for
278+
some requested Claims.
279+
280+
If a Claim is not returned, that Claim Name SHOULD be omitted from the
281+
JSON object representing the Claims; it SHOULD NOT be present with a
282+
null or empty string value.
283+
284+
The sub (subject) Claim MUST always be returned in the UserInfo
285+
Response.
286+
287+
Upon receipt of the UserInfo Request, the UserInfo Endpoint MUST return
288+
the JSON Serialization of the UserInfo Response as in Section 13.3 in
289+
the HTTP response body unless a different format was specified during
290+
Registration [OpenID.Registration].
291+
292+
If the UserInfo Response is signed and/or encrypted, then the Claims are
293+
returned in a JWT and the content-type MUST be application/jwt. The
294+
response MAY be encrypted without also being signed. If both signing and
295+
encryption are requested, the response MUST be signed then encrypted,
296+
with the result being a Nested JWT, as defined in [JWT].
297+
298+
If signed, the UserInfo Response SHOULD contain the Claims iss (issuer)
299+
and aud (audience) as members. The iss value SHOULD be the OP's Issuer
300+
Identifier URL. The aud value SHOULD be or include the RP's Client ID
301+
value.
302+
303+
:param request: OAuthlib request.
304+
:type request: oauthlib.common.Request
305+
:rtype: Claims as a dict OR JWT/JWS/JWE as a string
306+
307+
Method is used by:
308+
UserInfoEndpoint
309+
"""
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import absolute_import, unicode_literals
3+
4+
import mock
5+
import json
6+
7+
from oauthlib.openid import RequestValidator
8+
from oauthlib.openid import UserInfoEndpoint
9+
from oauthlib.oauth2.rfc6749 import errors
10+
11+
from tests.unittest import TestCase
12+
13+
14+
def set_scopes_valid(token, scopes, request):
15+
request.scopes = ["openid", "bar"]
16+
return True
17+
18+
19+
class UserInfoEndpointTest(TestCase):
20+
def setUp(self):
21+
self.claims = {
22+
"sub": "john",
23+
"fruit": "banana"
24+
}
25+
# Can't use MagicMock/wraps below.
26+
# Triggers error when endpoint copies to self.bearer.request_validator
27+
self.validator = RequestValidator()
28+
self.validator.validate_bearer_token = mock.Mock()
29+
self.validator.validate_bearer_token.side_effect = set_scopes_valid
30+
self.validator.get_userinfo_claims = mock.Mock()
31+
self.validator.get_userinfo_claims.return_value = self.claims
32+
self.endpoint = UserInfoEndpoint(self.validator)
33+
34+
self.uri = 'should_not_matter'
35+
self.headers = {
36+
'Authorization': 'Bearer eyJxx'
37+
}
38+
39+
def test_userinfo_no_auth(self):
40+
self.endpoint.create_userinfo_response(self.uri)
41+
42+
def test_userinfo_wrong_auth(self):
43+
self.headers['Authorization'] = 'Basic foifoifoi'
44+
self.endpoint.create_userinfo_response(self.uri, headers=self.headers)
45+
46+
def test_userinfo_token_expired(self):
47+
self.validator.validate_bearer_token.return_value = False
48+
self.endpoint.create_userinfo_response(self.uri, headers=self.headers)
49+
50+
def test_userinfo_token_no_openid_scope(self):
51+
def set_scopes_invalid(token, scopes, request):
52+
request.scopes = ["foo", "bar"]
53+
return True
54+
self.validator.validate_bearer_token.side_effect = set_scopes_invalid
55+
with self.assertRaises(errors.InsufficientScopeError) as context:
56+
self.endpoint.create_userinfo_response(self.uri)
57+
58+
def test_userinfo_json_response(self):
59+
h, b, s = self.endpoint.create_userinfo_response(self.uri)
60+
self.assertEqual(s, 200)
61+
body_json = json.loads(b)
62+
self.assertEqual(self.claims, body_json)
63+
self.assertEqual("application/json", h['Content-Type'])
64+
65+
def test_userinfo_jwt_response(self):
66+
self.validator.get_userinfo_claims.return_value = "eyJzzzzz"
67+
h, b, s = self.endpoint.create_userinfo_response(self.uri)
68+
self.assertEqual(s, 200)
69+
self.assertEqual(b, "eyJzzzzz")
70+
self.assertEqual("application/jwt", h['Content-Type'])

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy