From 77dcf3b07fe281a8b289d27878ab5b1c81353d8c Mon Sep 17 00:00:00 2001 From: Brian Candler Date: Tue, 22 Feb 2022 20:55:16 +0000 Subject: [PATCH] Changes to OpenID Connect * 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 --- social_core/backends/oauth.py | 22 ++++++--- social_core/backends/open_id_connect.py | 47 ++++++++++++++----- social_core/tests/backends/test_okta.py | 14 +++++- .../tests/backends/test_open_id_connect.py | 44 +++++++++++++++-- 4 files changed, 104 insertions(+), 23 deletions(-) diff --git a/social_core/backends/oauth.py b/social_core/backends/oauth.py index d1880896..2b3a1b73 100644 --- a/social_core/backends/oauth.py +++ b/social_core/backends/oauth.py @@ -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() @@ -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): diff --git a/social_core/backends/open_id_connect.py b/social_core/backends/open_id_connect.py index 4df9bd43..512850e5 100644 --- a/social_core/backends/open_id_connect.py +++ b/social_core/backends/open_id_connect.py @@ -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 = '' + SOCIAL_AUTH_OIDC_SECRET = '' """ + name = 'oidc' # Override OIDC_ENDPOINT in your subclass to enable autoconfig of OIDC OIDC_ENDPOINT = None ID_TOKEN_MAX_AGE = 600 @@ -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) @@ -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')) @@ -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') @@ -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 } diff --git a/social_core/tests/backends/test_okta.py b/social_core/tests/backends/test_okta.py index f6d8a245..4ac8f7c4 100644 --- a/social_core/tests/backends/test_okta.py +++ b/social_core/tests/backends/test_okta.py @@ -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) @@ -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() diff --git a/social_core/tests/backends/test_open_id_connect.py b/social_core/tests/backends/test_open_id_connect.py index cec80d0c..121560a5 100644 --- a/social_core/tests/backends/test_open_id_connect.py +++ b/social_core/tests/backends/test_open_id_connect.py @@ -54,7 +54,11 @@ 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() @@ -62,7 +66,7 @@ def setUp(self): 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 ) @@ -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'