From 62f49f41e05eaa1a17aeb01d269cec3e869f64c9 Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Mon, 25 Mar 2019 12:11:35 +0100 Subject: [PATCH 1/9] #338 Add ID4me backend --- CHANGELOG.md | 1 + requirements-id4me.txt | 3 + social_core/backends/id4me.py | 270 ++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 requirements-id4me.txt create mode 100644 social_core/backends/id4me.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5a3b408..e188a4ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased](https://github.com/python-social-auth/social-core/commits/master) +- ID4me backend ## [3.1.0](https://github.com/python-social-auth/social-core/releases/tag/3.1.0) - 2019-02-20 diff --git a/requirements-id4me.txt b/requirements-id4me.txt new file mode 100644 index 000000000..dfcdc6822 --- /dev/null +++ b/requirements-id4me.txt @@ -0,0 +1,3 @@ +python-jose>=3.0.0 +pyjwt>=1.7.1 +dnspython>=1.16.0 diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py new file mode 100644 index 000000000..b3f0310cd --- /dev/null +++ b/social_core/backends/id4me.py @@ -0,0 +1,270 @@ +""" + ID4me OpenID Connect backend, description at: https://id4me.org/for-developers/ +""" +import datetime +import json +import re +from calendar import timegm + +import dns +import jwt +import requests +from dns.resolver import NXDOMAIN, Timeout +from jose import jwk, jwt +from jose.jwt import JWTError, JWTClaimsError, ExpiredSignatureError +from social_core.backends.open_id_connect import OpenIdConnectAuth +from social_core.exceptions import AuthUnreachableProvider, AuthForbidden, AuthMissingParameter, AuthTokenError +from social_core.utils import handle_http_errors + + +class ID4meAssociation(object): + """ Use Association model to save the client account.""" + + def __init__(self, handle, secret='', issued=0, lifetime=0, assoc_type=''): + self.handle = handle # as client_id and client_secret + self.secret = secret.encode() # not use + self.issued = issued # not use + self.lifetime = lifetime # not use + self.assoc_type = assoc_type # as state + + def __str__(self): + return self.handle + + +def is_valid_domain(domain): + if domain[-1] == ".": + domain = domain[:-1] + allowed = re.compile("(?!-)[A-Z\d-]{1,63}(? id_token['iat'] + iat_leeway: + raise AuthTokenError(self, 'Incorrect id_token: iat') + + def validate_and_return_user_token(self, user_token): + client_id, client_secret = self.get_key_and_secret() + key = self.find_agent_valid_key(user_token) + + if not key: + raise AuthTokenError(self, 'Signature verification failed') + + alg = key['alg'] + rsakey = jwk.construct(key) + + try: + return jwt.decode( + user_token, + rsakey.to_pem().decode('utf-8'), + algorithms=[alg], + audience=client_id, + issuer=[self.strategy.session_get(self.name + '_agent'), + 'https://' + self.strategy.session_get(self.name + '_agent'), + self.strategy.session_get(self.name + '_authority').replace('https://', '')] + ) + except ExpiredSignatureError: + raise AuthTokenError(self, 'Signature has expired') + except JWTClaimsError as error: + raise AuthTokenError(self, str(error)) + except JWTError: + raise + + @handle_http_errors + def user_data(self, access_token, *args, **kwargs): + user_token = requests.get(self.userinfo_url(), headers={ + 'Authorization': 'Bearer {0}'.format(access_token) + }).text + return self.validate_and_return_user_token(user_token) + + def get_user_details(self, response): + data = { + self.setting('SOCIAL_AUTH_ID4ME_SCOPE_MAPPING', '')[key]: value for key, value in response.items() + if key in self.setting('SOCIAL_AUTH_ID4ME_SCOPE_MAPPING', '') + } + data.update(response.items()) + data['iss'] = self.strategy.session_get(self.name + '_authority') + data['clp'] = self.strategy.session_get(self.name + '_agent') + data['sub'] = response['sub'] + return data From 449878ee9c441d000d8d25f91096678718321aae Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Thu, 4 Apr 2019 17:05:01 +0300 Subject: [PATCH 2/9] Check for v=OID. Use only RS256 algorithm. Check exp claim on jwt --- social_core/backends/id4me.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index b3f0310cd..b3a60fcb7 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -73,7 +73,9 @@ def get_key_and_secret(self): issuer_configuration = self.oidc_config_authority() response = requests.post(issuer_configuration['registration_endpoint'], json={ 'client_name': self.setting('SOCIAL_AUTH_ID4ME_CLIENT_NAME', ''), - 'redirect_uris': [self.get_redirect_uri()] + 'redirect_uris': [self.get_redirect_uri()], + 'id_token_signed_response_alg': 'RS256', + 'userinfo_signed_response_alg': 'RS256' }) if response.status_code != 200: @@ -147,7 +149,7 @@ def find_valid_key(self, id_token): header = jwt.get_unverified_header(id_token) if header['kid'] == key['kid']: if 'alg' not in key: - key['alg'] = 'RS256' if key['kty'] == 'RSA' else 'ES256' + key['alg'] = 'RS256' return key def find_agent_valid_key(self, id_token): @@ -155,13 +157,15 @@ def find_agent_valid_key(self, id_token): header = jwt.get_unverified_header(id_token) if header['kid'] == key['kid']: if 'alg' not in key: - key['alg'] = 'RS256' if key['kty'] == 'RSA' else 'ES256' + key['alg'] = 'RS256' return key def auth_complete(self, *args, **kwargs): self.validate_state() identity = self.strategy.session_get(self.name + '_identity') openid_configuration = self.get_identity_record(identity) + if 'v' not in openid_configuration or openid_configuration['v'] != 'OID1': + raise AuthUnreachableProvider(self) if 'clp' not in openid_configuration: raise AuthUnreachableProvider(self) self.strategy.session_set(self.name + '_agent', openid_configuration['clp']) @@ -196,6 +200,8 @@ def auth_url(self): if not is_valid_domain(identity): raise AuthForbidden(self) openid_configuration = self.get_identity_record(identity) + if 'v' not in openid_configuration or openid_configuration['v'] != 'OID1': + raise AuthUnreachableProvider(self) if 'iss' not in openid_configuration: raise AuthUnreachableProvider(self) self.strategy.session_set(self.name + '_authority', openid_configuration['iss']) @@ -216,13 +222,8 @@ def auth_complete_credentials(self): def validate_claims(self, id_token): utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple()) - 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 - iat_leeway = self.setting('ID_TOKEN_MAX_AGE', self.ID_TOKEN_MAX_AGE) - if utc_timestamp > id_token['iat'] + iat_leeway: - raise AuthTokenError(self, 'Incorrect id_token: iat') + if utc_timestamp > id_token['exp']: + raise AuthTokenError(self, 'Incorrect id_token: exp') def validate_and_return_user_token(self, user_token): client_id, client_secret = self.get_key_and_secret() From 1b3e49aa5e55d6b1ad848f51ab89c3a96c255de8 Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Fri, 12 Apr 2019 16:24:24 +0300 Subject: [PATCH 3/9] Add support for default IAU --- social_core/backends/id4me.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index b3a60fcb7..869fce3d4 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -160,17 +160,6 @@ def find_agent_valid_key(self, id_token): key['alg'] = 'RS256' return key - def auth_complete(self, *args, **kwargs): - self.validate_state() - identity = self.strategy.session_get(self.name + '_identity') - openid_configuration = self.get_identity_record(identity) - if 'v' not in openid_configuration or openid_configuration['v'] != 'OID1': - raise AuthUnreachableProvider(self) - if 'clp' not in openid_configuration: - raise AuthUnreachableProvider(self) - self.strategy.session_set(self.name + '_agent', openid_configuration['clp']) - return super().auth_complete(*args, **kwargs) - def auth_params(self, state=None): client_id, client_secret = self.get_key_and_secret() params = { @@ -194,6 +183,9 @@ def auth_params(self, state=None): return params def auth_url(self): + if self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None): + self.strategy.session_set(self.name + '_authority', self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None)) + return super(ID4meBackend, self).auth_url() if not self.data.get('identity', ''): raise AuthMissingParameter(self, 'identity') identity = self.data.get('identity') @@ -219,6 +211,20 @@ def auth_complete_params(self, state=None): def auth_complete_credentials(self): return self.get_key_and_secret() + def validate_and_return_id_token(self, id_token, access_token): + claims = super(ID4meBackend, self).validate_and_return_id_token(id_token, access_token) + if self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None): + self.strategy.session_set(self.name + '_identity', claims.get('id4me.identifier', '')) + identity = self.strategy.session_get(self.name + '_identity') + openid_configuration = self.get_identity_record(identity) + if 'v' not in openid_configuration or openid_configuration['v'] != 'OID1': + raise AuthUnreachableProvider(self) + if 'clp' not in openid_configuration: + raise AuthUnreachableProvider(self) + self.strategy.session_set(self.name + '_agent', openid_configuration['clp']) + + return claims + def validate_claims(self, id_token): utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple()) From a474f6f22e94511d5b0e7a7bd8afecdf9bd649e3 Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Fri, 12 Apr 2019 16:48:23 +0300 Subject: [PATCH 4/9] Log IAU response --- social_core/backends/id4me.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index 869fce3d4..2bb2c636d 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -79,7 +79,7 @@ def get_key_and_secret(self): }) if response.status_code != 200: - raise AuthUnreachableProvider(self) + raise AuthUnreachableProvider(self, response.text) association = ID4meAssociation(response.text) self.strategy.storage.association.store(iau, association) data = json.loads(association.handle) From c01d517e63da23e374ff05b1158dcb5420797ab4 Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Fri, 12 Apr 2019 16:49:39 +0300 Subject: [PATCH 5/9] Log IAU response --- social_core/backends/id4me.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index 2bb2c636d..1b41a173b 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -79,7 +79,8 @@ def get_key_and_secret(self): }) if response.status_code != 200: - raise AuthUnreachableProvider(self, response.text) + error = response.text + raise AuthUnreachableProvider(self) association = ID4meAssociation(response.text) self.strategy.storage.association.store(iau, association) data = json.loads(association.handle) From d6f5609de563ead3b63909d77f7ceb49e3eff04b Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Fri, 12 Apr 2019 17:06:16 +0300 Subject: [PATCH 6/9] Add login hint only if known identity --- social_core/backends/id4me.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index 1b41a173b..ffb5550e4 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -174,9 +174,10 @@ def auth_params(self, state=None): params.update({ 'client_id': client_id, - 'redirect_uri': self.get_redirect_uri(state), - 'login_hint': self.strategy.session_get(self.name + '_identity') + 'redirect_uri': self.get_redirect_uri(state) }) + if self.strategy.session_get(self.name + '_identity'): + params['login_hint'] = self.strategy.session_get(self.name + '_identity') if self.STATE_PARAMETER and state: params['state'] = state if self.RESPONSE_TYPE: From a9c72c363ec842fe5109a69a0a00b0edfe885df5 Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Mon, 22 Apr 2019 17:57:42 +0300 Subject: [PATCH 7/9] Check identity to use the default iau if setting present --- social_core/backends/id4me.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index ffb5550e4..8d7edea78 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -185,12 +185,14 @@ def auth_params(self, state=None): return params def auth_url(self): - if self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None): - self.strategy.session_set(self.name + '_authority', self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None)) - return super(ID4meBackend, self).auth_url() - if not self.data.get('identity', ''): + identity = None + if self.data.get('identity', ''): + identity = self.data.get('identity') + if not identity and not self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None): raise AuthMissingParameter(self, 'identity') - identity = self.data.get('identity') + if not identity: + self.strategy.session_set(self.name + '_authority', self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU')) + return super(ID4meBackend, self).auth_url() if not is_valid_domain(identity): raise AuthForbidden(self) openid_configuration = self.get_identity_record(identity) @@ -198,6 +200,9 @@ def auth_url(self): raise AuthUnreachableProvider(self) if 'iss' not in openid_configuration: raise AuthUnreachableProvider(self) + if (self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None) and + openid_configuration['iss'] != self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU')): + raise AuthForbidden(self) self.strategy.session_set(self.name + '_authority', openid_configuration['iss']) self.strategy.session_set(self.name + '_identity', identity) return super(ID4meBackend, self).auth_url() From 225c27de27e9fcc15d21414b7b98af6a12042a6f Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Mon, 13 May 2019 16:19:15 +0300 Subject: [PATCH 8/9] Use auth forbidden exception --- social_core/backends/id4me.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index 8d7edea78..33a7b7173 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -197,9 +197,9 @@ def auth_url(self): raise AuthForbidden(self) openid_configuration = self.get_identity_record(identity) if 'v' not in openid_configuration or openid_configuration['v'] != 'OID1': - raise AuthUnreachableProvider(self) + raise AuthForbidden(self) if 'iss' not in openid_configuration: - raise AuthUnreachableProvider(self) + raise AuthForbidden(self) if (self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU', None) and openid_configuration['iss'] != self.setting('SOCIAL_AUTH_ID4ME_DEFAULT_IAU')): raise AuthForbidden(self) @@ -225,9 +225,9 @@ def validate_and_return_id_token(self, id_token, access_token): identity = self.strategy.session_get(self.name + '_identity') openid_configuration = self.get_identity_record(identity) if 'v' not in openid_configuration or openid_configuration['v'] != 'OID1': - raise AuthUnreachableProvider(self) + raise AuthForbidden(self) if 'clp' not in openid_configuration: - raise AuthUnreachableProvider(self) + raise AuthForbidden(self) self.strategy.session_set(self.name + '_agent', openid_configuration['clp']) return claims From 203a2bc66b905347007fa797e6f283230a26dcce Mon Sep 17 00:00:00 2001 From: Andreea Muscalu Date: Wed, 15 May 2019 12:55:21 +0300 Subject: [PATCH 9/9] Add identity to social auth details --- social_core/backends/id4me.py | 1 + 1 file changed, 1 insertion(+) diff --git a/social_core/backends/id4me.py b/social_core/backends/id4me.py index 33a7b7173..04ee73662 100644 --- a/social_core/backends/id4me.py +++ b/social_core/backends/id4me.py @@ -281,4 +281,5 @@ def get_user_details(self, response): data['iss'] = self.strategy.session_get(self.name + '_authority') data['clp'] = self.strategy.session_get(self.name + '_agent') data['sub'] = response['sub'] + data['identity'] = self.strategy.session_get(self.name + '_identity') return data