Skip to content

Commit

Permalink
Changes to OpenID Connect
Browse files Browse the repository at this point in the history
* Modify OpenIdConnectAuth so it can be directly instantiated
  (i.e. as SOCIAL_AUTH_OIDC_xxx) for use with a generic
  OpenID provider

* Update BaseOAuth2 so that it can perform HTTP Basic Auth for
  completing the auth process, configured from OIDC discovery

* Fix tests for okta, which previously were not calling
  oidc_config() from OktaMixin
  • Loading branch information
candlerb authored and nijel committed Jun 10, 2022
1 parent 893e5e6 commit 77dcf3b
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 23 deletions.
22 changes: 16 additions & 6 deletions social_core/backends/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,14 +297,18 @@ def access_token(self, token):
class BaseOAuth2(OAuthAuth):
"""Base class for OAuth2 providers.
OAuth2 draft details at:
http://tools.ietf.org/html/draft-ietf-oauth-v2-10
OAuth2 details at:
https://datatracker.ietf.org/doc/html/rfc6749
"""
REFRESH_TOKEN_URL = None
REFRESH_TOKEN_METHOD = 'POST'
RESPONSE_TYPE = 'code'
REDIRECT_STATE = True
STATE_PARAMETER = True
USE_BASIC_AUTH = False

def use_basic_auth(self):
return self.USE_BASIC_AUTH

def auth_params(self, state=None):
client_id, client_secret = self.get_key_and_secret()
Expand Down Expand Up @@ -332,16 +336,22 @@ def auth_url(self):
return f'{self.authorization_url()}?{params}'

def auth_complete_params(self, state=None):
client_id, client_secret = self.get_key_and_secret()
return {
params = {
'grant_type': 'authorization_code', # request auth code
'code': self.data.get('code', ''), # server response code
'client_id': client_id,
'client_secret': client_secret,
'redirect_uri': self.get_redirect_uri(state)
}
if not self.use_basic_auth():
client_id, client_secret = self.get_key_and_secret()
params.update({
'client_id': client_id,
'client_secret': client_secret,
})
return params

def auth_complete_credentials(self):
if self.use_basic_auth():
return self.get_key_and_secret()
return None

def auth_headers(self):
Expand Down
47 changes: 34 additions & 13 deletions social_core/backends/open_id_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ class OpenIdConnectAuth(BaseOAuth2):
"""
Base class for Open ID Connect backends.
Currently only the code response type is supported.
It can also be directly instantiated as a generic OIDC backend.
To use it you will need to set at minimum:
SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = 'https://.....' # endpoint without /.well-known/openid-configuration
SOCIAL_AUTH_OIDC_KEY = '<client_id>'
SOCIAL_AUTH_OIDC_SECRET = '<client_secret>'
"""
name = 'oidc'
# Override OIDC_ENDPOINT in your subclass to enable autoconfig of OIDC
OIDC_ENDPOINT = None
ID_TOKEN_MAX_AGE = 600
Expand All @@ -37,46 +45,58 @@ class OpenIdConnectAuth(BaseOAuth2):
REVOKE_TOKEN_METHOD = 'GET'
ID_KEY = 'sub'
USERNAME_KEY = 'preferred_username'
JWT_ALGORITHMS = ['RS256']
JWT_DECODE_OPTIONS = dict()
# When these options are unspecified, server will choose via openid autoconfiguration
ID_TOKEN_ISSUER = ''
ACCESS_TOKEN_URL = ''
AUTHORIZATION_URL = ''
REVOKE_TOKEN_URL = ''
USERINFO_URL = ''
JWKS_URI = ''
JWT_ALGORITHMS = ['RS256']
JWT_DECODE_OPTIONS = dict()
TOKEN_ENDPOINT_AUTH_METHOD = ''

def __init__(self, *args, **kwargs):
self.id_token = None
super().__init__(*args, **kwargs)

def authorization_url(self):
return self.AUTHORIZATION_URL or \
return self.setting('AUTHORIZATION_URL', self.AUTHORIZATION_URL) or \
self.oidc_config().get('authorization_endpoint')

def access_token_url(self):
return self.ACCESS_TOKEN_URL or \
return self.setting('ACCESS_TOKEN_URL', self.ACCESS_TOKEN_URL) or \
self.oidc_config().get('token_endpoint')

def revoke_token_url(self, token, uid):
return self.REVOKE_TOKEN_URL or \
return self.setting('REVOKE_TOKEN_URL', self.REVOKE_TOKEN_URL) or \
self.oidc_config().get('revocation_endpoint')

def id_token_issuer(self):
return self.ID_TOKEN_ISSUER or \
return self.setting('ID_TOKEN_ISSUER', self.ID_TOKEN_ISSUER) or \
self.oidc_config().get('issuer')

def userinfo_url(self):
return self.USERINFO_URL or \
return self.setting('USERINFO_URL', self.USERINFO_URL) or \
self.oidc_config().get('userinfo_endpoint')

def jwks_uri(self):
return self.JWKS_URI or \
return self.setting('JWKS_URI', self.JWKS_URI) or \
self.oidc_config().get('jwks_uri')

def use_basic_auth(self):
method = self.setting('TOKEN_ENDPOINT_AUTH_METHOD', self.TOKEN_ENDPOINT_AUTH_METHOD)
if method:
return method == 'client_secret_basic'
methods = self.oidc_config().get('token_endpoint_auth_methods_supported', [])
return not methods or 'client_secret_basic' in methods

def oidc_endpoint(self):
return self.setting('OIDC_ENDPOINT', self.OIDC_ENDPOINT)

@cache(ttl=86400)
def oidc_config(self):
return self.get_json(self.OIDC_ENDPOINT +
return self.get_json(self.oidc_endpoint() +
'/.well-known/openid-configuration')

@cache(ttl=86400)
Expand Down Expand Up @@ -160,7 +180,7 @@ def find_valid_key(self, id_token):
for key in keys:
if kid is None or kid == key.get('kid'):
if 'alg' not in key:
key['alg'] = self.JWT_ALGORITHMS[0]
key['alg'] = self.setting('JWT_ALGORITHMS', self.JWT_ALGORITHMS)[0]
rsakey = jwk.construct(key)
message, encoded_sig = id_token.rsplit('.', 1)
decoded_sig = base64url_decode(encoded_sig.encode('utf-8'))
Expand All @@ -186,11 +206,11 @@ def validate_and_return_id_token(self, id_token, access_token):
claims = jwt.decode(
id_token,
rsakey.to_pem().decode('utf-8'),
algorithms=self.JWT_ALGORITHMS,
algorithms=self.setting('JWT_ALGORITHMS', self.JWT_ALGORITHMS),
audience=client_id,
issuer=self.id_token_issuer(),
access_token=access_token,
options=self.JWT_DECODE_OPTIONS,
options=self.setting('JWT_DECODE_OPTIONS', self.JWT_DECODE_OPTIONS),
)
except ExpiredSignatureError:
raise AuthTokenError(self, 'Signature has expired')
Expand Down Expand Up @@ -221,11 +241,12 @@ def user_data(self, access_token, *args, **kwargs):
})

def get_user_details(self, response):
username_key = self.setting('USERNAME_KEY', default=self.USERNAME_KEY)
username_key = self.setting('USERNAME_KEY', 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'),
'groups': response.get('groups'), # not standardized but widely implemented
}
14 changes: 13 additions & 1 deletion social_core/tests/backends/test_okta.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ def setUp(self):
self.public_key = JWK_PUBLIC_KEY.copy()

HTTPretty.register_uri(HTTPretty.GET,
self.backend.OIDC_ENDPOINT + '/.well-known/openid-configuration',
# Note: okta.py strips the /oauth2 prefix using urljoin with absolute path
'https://dev-000000.oktapreview.com/.well-known/openid-configuration?client_id=a-key',
status=200,
body=self.openid_config_body)
oidc_config = json.loads(self.openid_config_body)
Expand All @@ -147,3 +148,14 @@ def jwks(_request, _uri, headers):

self.backend.JWKS_URI = oidc_config.get('jwks_uri')
self.backend.ID_TOKEN_ISSUER = oidc_config.get('issuer')

def pre_complete_callback(self, start_url):
super().pre_complete_callback(start_url)
HTTPretty.register_uri('GET',
uri=self.backend.userinfo_url(),
status=200,
body=json.dumps({'preferred_username': self.expected_username}),
content_type='text/json')

def test_everything_works(self):
self.do_login()
44 changes: 41 additions & 3 deletions social_core/tests/backends/test_open_id_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,19 @@ class OpenIdConnectTestMixin:
issuer = None # id_token issuer
openid_config_body = None
key = None
access_token_kwargs = {}
# Avoid sharing access_token_kwargs between different subclasses
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.access_token_kwargs = getattr(cls, 'access_token_kwargs', {})


def setUp(self):
super().setUp()
self.key = JWK_KEY.copy()
self.public_key = JWK_PUBLIC_KEY.copy()

HTTPretty.register_uri(HTTPretty.GET,
self.backend.OIDC_ENDPOINT + '/.well-known/openid-configuration',
self.backend.oidc_endpoint() + '/.well-known/openid-configuration',
status=200,
body=self.openid_config_body
)
Expand Down Expand Up @@ -200,12 +204,46 @@ def test_invalid_kid(self):
self.authtoken_raised('Token error: Signature verification failed', kid='doesnotexist')


class BaseOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
backend_path = \
'social_core.backends.open_id_connect.OpenIdConnectAuth'
issuer = 'https://example.com'
openid_config_body = json.dumps({
'issuer': 'https://example.com',
'authorization_endpoint': 'https://example.com/oidc/auth',
'token_endpoint': 'https://example.com/oidc/token',
'userinfo_endpoint': 'https://example.com/oidc/userinfo',
'revocation_endpoint': 'https://example.com/oidc/revoke',
'jwks_uri': 'https://example.com/oidc/certs',
})

expected_username = 'cartman'

def extra_settings(self):
settings = super().extra_settings()
settings.update({
f'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT': 'https://example.com/oidc',
})
return settings

def pre_complete_callback(self, start_url):
super().pre_complete_callback(start_url)
HTTPretty.register_uri('GET',
uri=self.backend.userinfo_url(),
status=200,
body=json.dumps({'preferred_username': self.expected_username}),
content_type='text/json')

def test_everything_works(self):
self.do_login()


class ExampleOpenIdConnectAuth(OpenIdConnectAuth):
name = 'example123'
OIDC_ENDPOINT = 'https://example.com/oidc'


class OpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
class ExampleOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
backend_path = \
'social_core.tests.backends.test_open_id_connect.ExampleOpenIdConnectAuth'
issuer = 'https://example.com'
Expand Down

0 comments on commit 77dcf3b

Please sign in to comment.