Skip to content

Commit

Permalink
Added Open ID Connect base backend
Browse files Browse the repository at this point in the history
See #300
  • Loading branch information
clintonb committed Aug 9, 2014
1 parent 83baa5f commit 1084d53
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 3 deletions.
1 change: 1 addition & 0 deletions requirements-python3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ requests>=1.1.0
oauthlib>=0.3.8
requests-oauthlib>=0.3.0,<0.3.2
six>=1.2.0
PyJWT==0.2.1
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ requests>=1.1.0
oauthlib>=0.3.8
requests-oauthlib>=0.3.0
six>=1.2.0
PyJWT==0.2.1
1 change: 1 addition & 0 deletions social/backends/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def get_user_id(self, details, response):

class GoogleOpenIdConnect(GoogleOAuth2, OpenIdConnectAuth):
name = 'google-openidconnect'
ID_TOKEN_ISSUER = "accounts.google.com"

def user_data(self, access_token, *args, **kwargs):
"""Return user data from Google API"""
Expand Down
119 changes: 117 additions & 2 deletions social/backends/open_id.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from calendar import timegm
import datetime
import jwt
from openid.consumer.consumer import Consumer, SUCCESS, CANCEL, FAILURE
from openid.consumer.discover import DiscoveryFailure
from openid.extensions import sreg, ax, pape

from social.utils import url_add_parameters
from social.exceptions import AuthException, AuthFailed, AuthCanceled, \
AuthUnknownError, AuthMissingParameter
AuthUnknownError, AuthMissingParameter, AuthTokenError
from social.backends.base import BaseAuth
from social.backends.oauth import BaseOAuth2

Expand Down Expand Up @@ -252,6 +255,118 @@ def openid_url(self):
raise AuthMissingParameter(self, OPENID_ID_FIELD)


class OpenIdConnectAssociation(object):
""" Use Association model to save the nonce by force. """

def __init__(self, handle, secret='', issued=0, lifetime=0, assoc_type=''):
self.handle = handle # as nonce
self.secret = secret.encode() # not use
self.issued = issued # not use
self.lifetime = lifetime # not use
self.assoc_type = assoc_type # as state


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']
EXTRA_DATA = ['id_token', 'refresh_token']
EXTRA_DATA = ['id_token', 'refresh_token', ('sub', 'id')]

# Set after access_token is retrieved
id_token = None

def auth_params(self, state=None):
"""Return extra arguments needed on auth process."""
params = super(OpenIdConnectAuth, self).auth_params(state)

params['nonce'] = self._get_and_store_nonce(self.AUTHORIZATION_URL, state)

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)

# Store the nonce
association = OpenIdConnectAssociation(nonce, assoc_type=state)
self.strategy.storage.association.store(url, association)

return nonce

def _get_nonce(self, nonce):
server_url = self.ACCESS_TOKEN_URL
try:
return self.strategy.storage.association.get(server_url=server_url, handle=nonce)[0]
except: # pylint: disable=bare-except
return None

def _remove_nonce(self, nonce_id):
try:
self.strategy.storage.association.remove([nonce_id])
except: # pylint: disable=bare-except
return None

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.
"""

client_id, _client_secret = self.get_key_and_secret()

try:
# Decode the JWT and raise an error if the secret is invalid or
# the response has expired.
decryption_key = self.setting('ID_TOKEN_DECRYPTION_KEY')
id_token = jwt.decode(id_token, decryption_key)
except (jwt.DecodeError, jwt.ExpiredSignature) as de:
raise AuthTokenError(self, de)

# Verify the issuer of the id_token is correct
if id_token['iss'] != self.ID_TOKEN_ISSUER:
raise AuthTokenError(self, 'Incorrect id_token: iss')

# Verify the token was issued in the last 10 minutes
utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple())
if id_token['iat'] < (utc_timestamp - 600):
raise AuthTokenError(self, 'Incorrect id_token: iat')

# Verify this client is the correct recipient of the id_token
aud = id_token.get('aud')
if aud != client_id:
raise AuthTokenError(self, 'Incorrect id_token: aud')

# Validate the nonce to ensure the request was not modified
nonce = id_token.get('nonce')
if not nonce:
raise AuthTokenError(self, 'Incorrect id_token: nonce')

nonce_obj = self._get_nonce(id_token['nonce'])
if nonce_obj:
self._remove_nonce(nonce_obj.id)
else:
raise AuthTokenError(self, 'Incorrect id_token: nonce')

return id_token

def request_access_token(self, *args, **kwargs):
"""
Retrieve the access token. Also, validate the id_token and
store it (temporarily).
"""
response = self.get_json(*args, **kwargs)
self.id_token = self._validate_and_return_id_token(response['id_token'])
return response
93 changes: 93 additions & 0 deletions social/tests/backends/open_id.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
from calendar import timegm
import json
import sys
import datetime
import jwt
import requests
from openid import oidutil
from social.exceptions import AuthTokenError

PY3 = sys.version_info[0] == 3

Expand Down Expand Up @@ -109,3 +114,91 @@ def do_start(self):
status=200,
body='is_valid:true\n')
return self.backend.complete()


class OpenIdConnectTestMixin(object):
"""
Mixin to test OpenID Connect consumers. Inheriting classes should also inherit OAuth2Test.
"""

client_key = 'a-key'
client_secret = 'a-secret-key'
issuer = None # id_token issuer

def setUp(self):
super(OpenIdConnectTestMixin, self).setUp()
self.access_token_body = self._parse_nonce_and_return_access_token_body

def extra_settings(self):
xs = super(OpenIdConnectTestMixin, self).extra_settings()
xs.update({
'SOCIAL_AUTH_{}_KEY'.format(self.name): self.client_key,
'SOCIAL_AUTH_{}_SECRET'.format(self.name): self.client_secret,
'SOCIAL_AUTH_{}_ID_TOKEN_DECRYPTION_KEY'.format(self.name): self.client_secret
})
return xs

def _parse_nonce_and_return_access_token_body(self, request, _url, headers):
"""
Get the nonce from the request parameters, add it to the id_token, and return the complete response.
"""
body = self.prepare_access_token_body(nonce=request.parsed_body[u'nonce'][0])
return 200, headers, body

def prepare_access_token_body(self, client_key=None, client_secret=None, expiration_datetime=None,
issue_datetime=None, nonce=None, issuer=None):
"""
Prepares a provider access token response
Arguments
client_id (str) -- OAuth ID for the client that requested authentication.
client_secret (str) -- OAuth secret for the client that requested authentication.
expiration_time (datetime) -- Date and time after which the response should be considered invalid.
"""

body = {'access_token': 'foobar', 'token_type': 'bearer'}
client_key = client_key or self.client_key
client_secret = client_secret or self.client_secret
now = datetime.datetime.utcnow()
expiration_datetime = expiration_datetime or (now + datetime.timedelta(seconds=30))
issue_datetime = issue_datetime or now
nonce = nonce or None
issuer = issuer or self.issuer

id_token = {
u'iss': issuer,
u'nonce': nonce,
u'aud': client_key,
u'azp': client_key,
u'exp': timegm(expiration_datetime.utctimetuple()),
u'iat': timegm(issue_datetime.utctimetuple()),
u'sub': u'1234',
}

body[u'id_token'] = jwt.encode(id_token, client_secret)

return json.dumps(body)

def assertAutTokenErrorRaised(self, expected_message, **access_token_kwargs):
self.access_token_body = self.prepare_access_token_body(**access_token_kwargs)
self.assertRaisesRegexp(AuthTokenError, expected_message, self.do_login)

def test_invalid_secret(self):
self.assertAutTokenErrorRaised('Token error: Signature verification failed', client_secret='wrong!')

def test_expired_signature(self):
expiration_datetime = datetime.datetime.utcnow() - datetime.timedelta(seconds=30)
self.assertAutTokenErrorRaised('Token error: Signature has expired', expiration_datetime=expiration_datetime)

def test_invalid_issuer(self):
self.assertAutTokenErrorRaised('Token error: Incorrect id_token: iss', issuer='someone-else')

def test_invalid_audience(self):
self.assertAutTokenErrorRaised('Token error: Incorrect id_token: aud', client_key='someone-else')

def test_invalid_issue_time(self):
expiration_datetime = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
self.assertAutTokenErrorRaised('Token error: Incorrect id_token: iat', issue_datetime=expiration_datetime)

def test_invalid_nonce(self):
self.assertAutTokenErrorRaised('Token error: Incorrect id_token: nonce', nonce='something-wrong')
8 changes: 7 additions & 1 deletion social/tests/backends/test_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from social.tests.models import User
from social.tests.backends.oauth import OAuth1Test, OAuth2Test
from social.tests.backends.open_id import OpenIdTest
from social.tests.backends.open_id import OpenIdTest, OpenIdConnectTestMixin


class GoogleOAuth2Test(OAuth2Test):
Expand Down Expand Up @@ -280,3 +280,9 @@ def test_revoke_token(self):
self.backend.REVOKE_TOKEN_URL,
status=200)
do_disconnect(self.backend, user)


class GoogleOpenIdConnectTest(OpenIdConnectTestMixin, GoogleOAuth2Test):
backend_path = 'social.backends.google.GoogleOpenIdConnect'
user_data_url = 'https://www.googleapis.com/plus/v1/people/me/openIdConnect'
issuer = "accounts.google.com"

0 comments on commit 1084d53

Please sign in to comment.