-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix google OpenID Connect #747
Changes from all commits
d4d0647
a57d97b
d1d60ac
f5f2647
4ef0bc9
fdec758
ae4dac0
8b0a17d
44df046
e623a6c
141ce6e
271710f
0ee7825
c21874e
8df4528
fe86537
768f760
c3cd845
db81203
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,3 +4,4 @@ oauthlib>=0.3.8 | |
requests-oauthlib>0.3.2 | ||
six>=1.2.0 | ||
PyJWT>=1.0.0 | ||
pyjwkest==1.0.1 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,3 +4,4 @@ oauthlib>=0.3.8 | |
requests-oauthlib>=0.3.1 | ||
six>=1.2.0 | ||
PyJWT>=1.0.0 | ||
pyjwkest==1.0.1 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,11 @@ | ||
import datetime | ||
import six | ||
import time | ||
from calendar import timegm | ||
|
||
from jwt import InvalidTokenError, decode as jwt_decode | ||
from jwkest import JWKESTException | ||
from jwkest.jwk import KEYS | ||
from jwkest.jws import JWS | ||
|
||
from openid.consumer.consumer import Consumer, SUCCESS, CANCEL, FAILURE | ||
from openid.consumer.discover import DiscoveryFailure | ||
|
@@ -271,17 +275,87 @@ def __init__(self, handle, secret='', issued=0, lifetime=0, assoc_type=''): | |
self.assoc_type = assoc_type # as state | ||
|
||
|
||
class _cache(object): | ||
""" | ||
Cache decorator that caches the return value of a method for a | ||
specified time. | ||
|
||
It maintains a cache per class, so subclasses have a different cache entry | ||
for the same cached method. | ||
|
||
Does not work for methods with arguments. | ||
""" | ||
def __init__(self, ttl): | ||
self.ttl = ttl | ||
self.cache = {} | ||
|
||
def __call__(self, fn): | ||
def wrapped(this): | ||
now = time.time() | ||
last_updated = None | ||
cached_value = None | ||
if this.__class__ in self.cache: | ||
last_updated, cached_value = self.cache[this.__class__] | ||
if not cached_value or now - last_updated > self.ttl: | ||
try: | ||
cached_value = fn(this) | ||
self.cache[this.__class__] = (now, cached_value) | ||
except: | ||
# Use previously cached value when call fails, if available | ||
if not cached_value: | ||
raise | ||
return cached_value | ||
return wrapped | ||
|
||
|
||
def _autoconf(name): | ||
""" | ||
fget helper function to fetch the value of a property from the OIDC | ||
configuration | ||
""" | ||
def getter(self): | ||
return self.oidc_config().get(name) | ||
return getter | ||
|
||
|
||
class OpenIdConnectAuth(BaseOAuth2): | ||
""" | ||
Base class for Open ID Connect backends. | ||
|
||
Currently only the code response type is supported. | ||
""" | ||
ID_TOKEN_ISSUER = None | ||
DEFAULT_SCOPE = ['openid'] | ||
# Override OIDC_ENDPOINT in your subclass to enable autoconfig of OIDC | ||
OIDC_ENDPOINT = None | ||
|
||
DEFAULT_SCOPE = ['openid', 'profile', 'email'] | ||
EXTRA_DATA = ['id_token', 'refresh_token', ('sub', 'id')] | ||
# Set after access_token is retrieved | ||
id_token = None | ||
REDIRECT_STATE = False | ||
ACCESS_TOKEN_METHOD = 'POST' | ||
REVOKE_TOKEN_METHOD = 'GET' | ||
ID_KEY = 'sub' | ||
USERNAME_KEY = 'preferred_username' | ||
|
||
@_cache(ttl=86400) | ||
def oidc_config(self): | ||
return self.get_json(self.OIDC_ENDPOINT + | ||
'/.well-known/openid-configuration') | ||
|
||
ID_TOKEN_ISSUER = property(_autoconf('issuer')) | ||
ACCESS_TOKEN_URL = property(_autoconf('token_endpoint')) | ||
AUTHORIZATION_URL = property(_autoconf('authorization_endpoint')) | ||
REVOKE_TOKEN_URL = property(_autoconf('revocation_endpoint')) | ||
USERINFO_URL = property(_autoconf('userinfo_endpoint')) | ||
JWKS_URI = property(_autoconf('jwks_uri')) | ||
|
||
@_cache(ttl=86400) | ||
def get_jwks_keys(self): | ||
keys = KEYS() | ||
keys.load_from_url(self.JWKS_URI) | ||
|
||
# Add client secret as oct key so it can be used for HMAC signatures | ||
_client_id, client_secret = self.get_key_and_secret() | ||
keys.add({'key': client_secret, 'kty': 'oct'}) | ||
return keys | ||
|
||
def auth_params(self, state=None): | ||
"""Return extra arguments needed on auth process.""" | ||
|
@@ -291,14 +365,6 @@ def auth_params(self, state=None): | |
) | ||
return params | ||
|
||
def auth_complete_params(self, state=None): | ||
params = super(OpenIdConnectAuth, self).auth_complete_params(state) | ||
# Add a nonce to the request so that to help counter CSRF | ||
params['nonce'] = self.get_and_store_nonce( | ||
self.ACCESS_TOKEN_URL, state | ||
) | ||
return params | ||
|
||
def get_and_store_nonce(self, url, state): | ||
# Create a nonce | ||
nonce = self.strategy.random_string(64) | ||
|
@@ -310,7 +376,7 @@ def get_and_store_nonce(self, url, state): | |
def get_nonce(self, nonce): | ||
try: | ||
return self.strategy.storage.association.get( | ||
server_url=self.ACCESS_TOKEN_URL, | ||
server_url=self.AUTHORIZATION_URL, | ||
handle=nonce | ||
)[0] | ||
except IndexError: | ||
|
@@ -319,25 +385,31 @@ def get_nonce(self, nonce): | |
def remove_nonce(self, nonce_id): | ||
self.strategy.storage.association.remove([nonce_id]) | ||
|
||
def validate_and_return_id_token(self, id_token): | ||
""" | ||
Validates the id_token according to the steps at | ||
http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation. | ||
""" | ||
def validate_claims(self, id_token): | ||
if id_token['iss'] != self.ID_TOKEN_ISSUER: | ||
raise AuthTokenError(self, 'Token error: Invalid issuer') | ||
|
||
client_id, _client_secret = self.get_key_and_secret() | ||
decryption_key = self.setting('ID_TOKEN_DECRYPTION_KEY') | ||
try: | ||
# Decode the JWT and raise an error if the secret is invalid or | ||
# the response has expired. | ||
id_token = jwt_decode(id_token, decryption_key, audience=client_id, | ||
issuer=self.ID_TOKEN_ISSUER, | ||
algorithms=['HS256']) | ||
except InvalidTokenError as err: | ||
raise AuthTokenError(self, err) | ||
if isinstance(id_token['aud'], six.string_types): | ||
id_token['aud'] = [id_token['aud']] | ||
if client_id not in id_token['aud']: | ||
raise AuthTokenError(self, 'Token error: Invalid audience') | ||
|
||
if len(id_token['aud']) > 1 and 'azp' not in id_token: | ||
raise AuthTokenError(self, 'Incorrect id_token: azp') | ||
|
||
if 'azp' in id_token and id_token['azp'] != client_id: | ||
raise AuthTokenError(self, 'Incorrect id_token: azp') | ||
|
||
# Verify the token was issued in the last 10 minutes | ||
utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple()) | ||
if id_token['iat'] < (utc_timestamp - 600): | ||
if utc_timestamp > id_token['exp']: | ||
raise AuthTokenError(self, 'Token error: Signature has expired') | ||
|
||
if 'nbf' in id_token and utc_timestamp < id_token['nbf']: | ||
raise AuthTokenError(self, 'Incorrect id_token: nbf') | ||
|
||
# Verify the token was issued in the last 10 minutes | ||
if utc_timestamp > id_token['iat'] + 600: | ||
raise AuthTokenError(self, 'Incorrect id_token: iat') | ||
|
||
# Validate the nonce to ensure the request was not modified | ||
|
@@ -350,6 +422,22 @@ def validate_and_return_id_token(self, id_token): | |
self.remove_nonce(nonce_obj.id) | ||
else: | ||
raise AuthTokenError(self, 'Incorrect id_token: nonce') | ||
|
||
def validate_and_return_id_token(self, jws): | ||
""" | ||
Validates the id_token according to the steps at | ||
http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation. | ||
""" | ||
try: | ||
# Decode the JWT and raise an error if the sig is invalid | ||
id_token = JWS().verify_compact(jws.encode('utf-8'), | ||
self.get_jwks_keys()) | ||
except JWKESTException: | ||
raise AuthTokenError(self, | ||
'Token error: Signature verification failed') | ||
|
||
self.validate_claims(id_token) | ||
|
||
return id_token | ||
|
||
def request_access_token(self, *args, **kwargs): | ||
|
@@ -360,3 +448,18 @@ def request_access_token(self, *args, **kwargs): | |
response = self.get_json(*args, **kwargs) | ||
self.id_token = self.validate_and_return_id_token(response['id_token']) | ||
return response | ||
|
||
def user_data(self, access_token, *args, **kwargs): | ||
return self.get_json(self.USERINFO_URL, | ||
headers={'Authorization': | ||
'Bearer {0}'.format(access_token)}) | ||
|
||
def get_user_details(self, response): | ||
username_key = self.setting('USERNAME_KEY', default=self.USERNAME_KEY) | ||
return { | ||
'username': response.get(username_key), | ||
'email': response.get('email'), | ||
'fullname': response.get('name'), | ||
'first_name': response.get('given_name'), | ||
'last_name': response.get('family_name'), | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be closer to values = {'username': '', 'email': '', 'fullname': '',
'first_name': '', 'last_name': ''}
fullname = values.get('name') or ''
first_name = values.get('given_name') or ''
last_name = values.get('family_name') or ''
email = values.get('email') or ''
if not fullname and first_name and last_name:
fullname = first_name + ' ' + last_name
elif fullname:
try:
first_name, last_name = fullname.rsplit(' ', 1)
except ValueError:
last_name = fullname
username_key = self.setting('USERNAME_KEY') or self.USERNAME_KEY
values.update({'fullname': fullname, 'first_name': first_name,
'last_name': last_name,
'username': values.get(username_key) or
(first_name.title() + last_name.title()),
'email': email})
return values There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I appreciate your suggestion, but these fields have been standardized for OIDC in http://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.1. I therefore would prefer to use these as a sensible default. I guess most users would override the OpenIdConnectAuth to customize this for the actual values the backend provides (if the backend doesn't adhere to the RFC). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was mostly talking about Which lets users override the username in their own settings file and exists in other backends. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added the USERNAME_KEY setting. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There should be a
USERNAME_KEY = 'preferred_username'