From f2e8f2ab77059d5fa6a635973a6273646f76e2df Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Tue, 16 Apr 2024 12:24:35 +0100 Subject: [PATCH 1/4] feat(providers): Added Xero provider --- .../socialaccount/providers/xero/__init__.py | 0 .../socialaccount/providers/xero/provider.py | 50 ++++++ allauth/socialaccount/providers/xero/tests.py | 151 ++++++++++++++++++ allauth/socialaccount/providers/xero/urls.py | 6 + allauth/socialaccount/providers/xero/views.py | 74 +++++++++ 5 files changed, 281 insertions(+) create mode 100644 allauth/socialaccount/providers/xero/__init__.py create mode 100644 allauth/socialaccount/providers/xero/provider.py create mode 100644 allauth/socialaccount/providers/xero/tests.py create mode 100644 allauth/socialaccount/providers/xero/urls.py create mode 100644 allauth/socialaccount/providers/xero/views.py diff --git a/allauth/socialaccount/providers/xero/__init__.py b/allauth/socialaccount/providers/xero/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/allauth/socialaccount/providers/xero/provider.py b/allauth/socialaccount/providers/xero/provider.py new file mode 100644 index 0000000000..e2fe710f75 --- /dev/null +++ b/allauth/socialaccount/providers/xero/provider.py @@ -0,0 +1,50 @@ +from allauth.account.models import EmailAddress +from allauth.socialaccount.providers.base import ProviderAccount +from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider + + +class XeroAccount(ProviderAccount): + def to_str(self): + fallback = super(XeroAccount, self).to_str() + + # If the extra_data is malformed, exit early + if not isinstance(self.account.extra_data, dict): + return fallback + + display_name = self.account.extra_data.get("name") + # It's very unlikely but still possible that the display_name is None + # so we'll return or'd against the fallback just incase. We don't want + # to return None as users of the library expect this to be str. + return display_name or fallback + + def get_avatar_url(self): + return None + + +class XeroProvider(OAuth2Provider): + id = "xero" + name = "Xero" + account_class = XeroAccount + + def extract_uid(self, data): + return str(data["xero_userid"]) + + def extract_common_fields(self, data): + return dict( + email_address=data.get("email"), + username=data.get("preferred_username"), + name=data.get("name"), + ) + + def get_default_scope(self): + return ["openid", "email", "profile"] + + def extract_email_addresses(self, data): + ret = [] + email = data.get("email") + if email is not None: + ret.append(EmailAddress(email=email, verified=True, primary=True)) + return ret + + +provider_classes = [XeroProvider] diff --git a/allauth/socialaccount/providers/xero/tests.py b/allauth/socialaccount/providers/xero/tests.py new file mode 100644 index 0000000000..405284a48c --- /dev/null +++ b/allauth/socialaccount/providers/xero/tests.py @@ -0,0 +1,151 @@ +import json +from django.contrib.auth import get_user_model +from django.utils.timezone import datetime, timedelta + +from allauth.account.models import EmailAddress +from allauth.account.utils import user_email, user_username +from allauth.socialaccount.models import SocialAccount +from allauth.socialaccount.tests import OAuth2TestsMixin +from allauth.tests import MockedResponse, TestCase +from allauth.socialaccount.providers.apple.client import jwt_encode + +import jwt + +from .provider import XeroProvider + +TESTING_JWT_KEYSET = { + "p": "3bHBzm9CAUjPOLHe5133Lcnl7OIvlwbSNo166pWNwJGSwlAj5NMhnvKZ7Qti5NBILfNmFS6Mm1QNH5yQN8f8Okin84sXcgdT9B9dNzudu0QcLMcKCHDViqNLbHdcDh0O9ZhOdtYz0wziwSgY4jWxL7XMNJXIcGIIsH2i50ol3-M", + "kty": "RSA", + "q": "wn99EF_GNulcQHRMTMsXFBCr523i5i_ZZI1v4EvpLpQc4tMZ2W-x9VNPeqImYD8I_N8uFHDlUsoucujBvilhqazmI1GIvDEy2N3-VlnTvozmkhHCPO9Sab5il4wsa_9hEgI1vMg-03V6Vsq4TCYfiD50MHeQLBdLcfBAECTl0Lk", + "d": "ajHjxzabSfgwYIo192z1q8cFPATC8zGuRrd6XaXHmuSHO9-KldzV6-8nI2ggqd2AgNblglfljslGQIDoOjTzHyR6xBe6An8B6M9h8tJkeqX-JQG0DSGVEKIzDq4uksLQTK6mD7FF7MzkVoCMHz0XGQERyq0dmYCrM5pBRYYFWkxk62wcLBWyBo-TJB1A7PNwsjJySVUmvp5aaJ_yQwREvmFbsf1c9G6o2GnBcLDkYn5RV46rDJ4SIGOmfM5jTnji7SMEf9i_kTIKJvz8UNZHFNDl6ahy5p8KBWbvU8LH7m64zWPadvnHfXJGRJFf5roEKrW0h_5WptPoGmdpgdetgQ", + "e": "AQAB", + "use": "sig", + "kid": "VfOUABw3YmRi0ri0DB1tz1XDZHnkqqhKHBbFz30T0D4", + "qi": "giWWsyaNU4YOCSGSN0eS3nQIGE96B3CJq6HQ0Ftda8vcMIeZf3TAIgxB_kxN1urh3YwqYmr2W-2VxsaAM066rFumJmyyB5h83-huE9FwKKv6c4rrIWIpp653r5b4-9bVRx4xlpMdhTTQtcbvjj4QDOi-CrxicthyW5qMkR4PJtI", + "dp": "I_LGDXZnCpRG3deh4HyRL0CU4wOOWfwGLEhmzRExKi-wz4d1Oo6t3ftS0GhPQfEwMxtLy1WAAVPwyNZ3YEQydzT-3vQH-jqL94L6d5FYM1yJAQ3JZ7L8PX3bJhx4teUqXtKyrnxvbOKjBlU9K7kvISBmm4RKO0b6R7wnpT-Vwqc", + "alg": "RS256", + "dq": "q-bH32f2pWO9IE5pfVnmLNrLRIFPkEjsJ74GCkStdHh9y0_uwcnBjGU0kturdVdhFzYd4P0jAfgl83OagPrMEY353W9bnZESMrCJ8UH1Lq4Tvzgo53hR65nUQ8MlI9KTtbn0SsTlGjnzhbAoEU2EgwNH5-pUp1NzX-GKjXo_ECk", + "n": "qG8cW49nvecVapRWQI0Kz0H5-NNWCyGQbtyRdCFW_Vlgs9yNSr1PTsoLHkd_Pn1Di8SP5J_YMQ--PTTwdMGU91-Saq2QDD-q_veXVW0i7K9pyflQu6-FZDbwsKe1dgV4lkXNu0AFvM7jhYHTSqbGGNUxlmvN4cFH2GHeyl3xO1iB25rPS9jGUssIEOh4xWil6N0YiWorQedJqh-MAHuXe_dHSJHY8BOMyHr8rvzvVZ2360-690exinev3Z_gaZYa6fG5TVFrS9ErBQjmcG9LZM_Cuo2zDkmpkR5oTJqj83CFRlXbqOH2ZvcposS38zJg2WY1KJ_Tq80QoArwjVY7Cw", +} + + +KEY_SERVER_RESP_JSON = json.dumps( + { + "keys": [ + { + "kty": TESTING_JWT_KEYSET["kty"], + "kid": TESTING_JWT_KEYSET["kid"], + "use": TESTING_JWT_KEYSET["use"], + "alg": TESTING_JWT_KEYSET["alg"], + "n": TESTING_JWT_KEYSET["n"], + "e": TESTING_JWT_KEYSET["e"], + "x5t": "bodUTlQZd3BX3yyLdioJ4LxNrKU=", + "x5c": [ + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqG8cW49nvecVapRWQI0Kz0H5+NNWCyGQbtyRdCFW/Vlgs9yNSr1PTsoLHkd/Pn1Di8SP5J/YMQ++PTTwdMGU91+Saq2QDD+q/veXVW0i7K9pyflQu6+FZDbwsKe1dgV4lkXNu0AFvM7jhYHTSqbGGNUxlmvN4cFH2GHeyl3xO1iB25rPS9jGUssIEOh4xWil6N0YiWorQedJqh+MAHuXe/dHSJHY8BOMyHr8rvzvVZ2360+690exinev3Z/gaZYa6fG5TVFrS9ErBQjmcG9LZM/Cuo2zDkmpkR5oTJqj83CFRlXbqOH2ZvcposS38zJg2WY1KJ/Tq80QoArwjVY7CwIDAQAB" + ], + } + ] + } +) + + +def sign_id_token(payload): + """ + Sign a payload as apple normally would for the id_token. + """ + signing_key = jwt.algorithms.RSAAlgorithm.from_jwk( + json.dumps(TESTING_JWT_KEYSET) + ) + return jwt_encode( + payload, + signing_key, + algorithm="RS256", + headers={"kid": TESTING_JWT_KEYSET["kid"]}, + ) + + +class XeroTests(OAuth2TestsMixin, TestCase): + provider_id = XeroProvider.id + + def get_xero_id_token_payload(self): + now = datetime.now() + client_id = "app123id" # Matches `setup_app` + payload = { + "iss": "https://identity.xero.com", + "aud": client_id, + "sub": "108204268033311374519", + "email": "raymond@example.com", + "name": "Raymond Penners", + "given_name": "Raymond", + "family_name": "Penners", + "xero_userid": "4eaedef4-ffba-4a9c-903f-c811ba031794", + "iat": now, + "exp": now + timedelta(hours=1), + } + # payload.update(self.identity_overwrites) + return payload + + def get_login_response_json(self, with_refresh_token=True): + rt = "" + if with_refresh_token: + rt = ',"refresh_token": "testrf"' + return """{ + "id_token": "%s", + "access_token":"%s", + "token_type": "Bearer", + "expires_in": 12345 + %s }""" % ( + sign_id_token(self.get_xero_id_token_payload()), + sign_id_token(self.get_xero_id_token_payload()), + rt, + ) + + def get_mocked_response(self): + return [ + MockedResponse( + 200, KEY_SERVER_RESP_JSON, {"content-type": "application/json"} + ), + MockedResponse( + 200, KEY_SERVER_RESP_JSON, {"content-type": "application/json"} + ), + # MockedResponse( + # 200, + # """{ + # "id": "80351110224678912", + # "username": "nelly@example.com", + # "discriminator": "0", + # "global_name": "Nelly", + # "avatar": "8342729096ea3675442027381ff50dfe", + # "verified": true, + # "email":"nelly@example.com" + # }""", + # ), + ] + + def test_display_name(self, multiple_login=False): + email = "user@example.com" + user = get_user_model()(is_active=True) + user_email(user, email) + user_username(user, "user") + user.set_password("test") + user.save() + EmailAddress.objects.create( + user=user, email=email, primary=True, verified=True + ) + self.client.login(username=user.email_address, password="test") + self.login(self.get_mocked_response(), process="connect") + if multiple_login: + self.login( + self.get_mocked_response(), + with_refresh_token=False, + process="connect", + ) + + # get account + sa = SocialAccount.objects.filter( + user=user, provider=self.provider.id + ).get() + # The following lines don't actually test that much, but at least + # we make sure that the code is hit. + provider_account = sa.get_provider_account() + self.assertEqual(provider_account.to_str(), "Nelly") diff --git a/allauth/socialaccount/providers/xero/urls.py b/allauth/socialaccount/providers/xero/urls.py new file mode 100644 index 0000000000..b5791f3fd0 --- /dev/null +++ b/allauth/socialaccount/providers/xero/urls.py @@ -0,0 +1,6 @@ +from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns + +from .provider import XeroProvider + + +urlpatterns = default_urlpatterns(XeroProvider) diff --git a/allauth/socialaccount/providers/xero/views.py b/allauth/socialaccount/providers/xero/views.py new file mode 100644 index 0000000000..ebe69a42a7 --- /dev/null +++ b/allauth/socialaccount/providers/xero/views.py @@ -0,0 +1,74 @@ +# from time import sleep + +# import jwt + +from allauth.socialaccount.providers.oauth2.views import ( + OAuth2Adapter, + OAuth2CallbackView, + OAuth2LoginView, +) + +from allauth.socialaccount.models import SocialToken +from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.internal import jwtkit + + +from .provider import XeroProvider + +from django.utils import timezone + + +class XeroOAuth2Adapter(OAuth2Adapter): + provider_id = XeroProvider.id + access_token_url = "https://identity.xero.com/connect/token" + refresh_token_url = "https://identity.xero.com/connect/token" + authorize_url = "https://login.xero.com/identity/connect/authorize" + profile_url = "https://api.xero.com/api.xro/2.0/Users" + + public_key_url = ( + "https://identity.xero.com/.well-known/openid-configuration/jwks" + ) + + tenants_url = "https://api.xero.com/connections" + + def get_verified_identity_data(self, id_token): + app = get_adapter().get_app(request=None, provider=self.provider_id) + data = jwtkit.verify_and_decode( + credential=id_token, + keys_url=self.public_key_url, + issuer="https://identity.xero.com", + audience=app.client_id, + lookup_kid=jwtkit.lookup_kid_jwk, + ) + return data + + def parse_token(self, data): + token = SocialToken( + token=data["access_token"], + ) + token.token_secret = data.get("refresh_token", "") + + expires_in = data.get(self.expires_in_key) + if expires_in: + token.expires_at = timezone.now() + timezone.timedelta( + seconds=int(expires_in) + ) + + # `user_data` is a big flat dictionary with the parsed JWT claims + # access_tokens, and user info from the apple post. + identity_data = self.get_verified_identity_data(data["id_token"]) + token.user_data = {**data, **identity_data} + + return token + + def complete_login(self, request, app, token, **kwargs): + extra_data = token.user_data + login = self.get_provider().sociallogin_from_response( + request=request, response=extra_data + ) + login.state["id_token"] = token.user_data + return login + + +oauth2_login = OAuth2LoginView.adapter_view(XeroOAuth2Adapter) +oauth2_callback = OAuth2CallbackView.adapter_view(XeroOAuth2Adapter) From b913cab6c113e466da154460e7d3dc9733fff0c1 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Tue, 16 Apr 2024 12:42:54 +0100 Subject: [PATCH 2/4] Fixup add docs and other misc files --- AUTHORS | 1 + ChangeLog.rst | 2 ++ docs/installation/quickstart.rst | 1 + docs/socialaccount/providers/index.rst | 1 + docs/socialaccount/providers/xero.rst | 25 +++++++++++++++++++++++++ test_settings.py | 1 + 6 files changed, 31 insertions(+) create mode 100644 docs/socialaccount/providers/xero.rst diff --git a/AUTHORS b/AUTHORS index ab41bdf6a2..12d944709b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Andrew Chen Wang Andrey Akolpakov Andrey Balandin Andy Matthews +Andy Miller Ani Vera Anna Sirota Antonin Delpeuch diff --git a/ChangeLog.rst b/ChangeLog.rst index 6537638cbd..073c3bb0ae 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -6,6 +6,8 @@ Note worthy changes - Added a dummy provider, useful for testing purposes: ``allauth.socialaccount.providers.dummy``. +- New provider: Xero + 0.61.1 (2024-02-09) ******************* diff --git a/docs/installation/quickstart.rst b/docs/installation/quickstart.rst index 687a184d0c..5302a39f25 100644 --- a/docs/installation/quickstart.rst +++ b/docs/installation/quickstart.rst @@ -154,6 +154,7 @@ the ``settings.py`` of your project:: 'allauth.socialaccount.providers.weixin', 'allauth.socialaccount.providers.windowslive', 'allauth.socialaccount.providers.xing', + 'allauth.socialaccount.providers.xero', 'allauth.socialaccount.providers.yahoo', 'allauth.socialaccount.providers.yandex', 'allauth.socialaccount.providers.ynab', diff --git a/docs/socialaccount/providers/index.rst b/docs/socialaccount/providers/index.rst index 862e5a5d48..00b8a96a25 100644 --- a/docs/socialaccount/providers/index.rst +++ b/docs/socialaccount/providers/index.rst @@ -133,6 +133,7 @@ Provider Specifics weixin windowslive xing + xero yahoo yandex ynab diff --git a/docs/socialaccount/providers/xero.rst b/docs/socialaccount/providers/xero.rst new file mode 100644 index 0000000000..390374d63c --- /dev/null +++ b/docs/socialaccount/providers/xero.rst @@ -0,0 +1,25 @@ +Xero +----- + +App registration (create an App here) + https://developer.xero.com/app/manage/ + +Development callback URL + http://domain.com/accounts/xero/login/callback/ + +Add the following configuration to your settings: + +.. code-block:: python + + SOCIALACCOUNT_PROVIDERS = { + "xero": { + "APP": { + # Your Client ID (managed in the "Configuration" page). + "client_id": "your_client_id", + # The Client Secret (managed in the "Configuration" page). + "secret": "secret", + } + } + } + } + diff --git a/test_settings.py b/test_settings.py index 8751714153..c11a8976e1 100644 --- a/test_settings.py +++ b/test_settings.py @@ -187,6 +187,7 @@ "allauth.socialaccount.providers.weixin", "allauth.socialaccount.providers.windowslive", "allauth.socialaccount.providers.xing", + "allauth.socialaccount.providers.xero", "allauth.socialaccount.providers.yahoo", "allauth.socialaccount.providers.yandex", "allauth.socialaccount.providers.ynab", From f5a1d509d56dbbb19b3649d80a2c7d1f6a6780cb Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Tue, 16 Apr 2024 16:53:38 +0100 Subject: [PATCH 3/4] update version number --- allauth/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/allauth/__init__.py b/allauth/__init__.py index d5f53c8873..b6e7039606 100644 --- a/allauth/__init__.py +++ b/allauth/__init__.py @@ -8,7 +8,7 @@ """ -VERSION = (0, 62, 0, "dev", 0) +VERSION = (0, 62, 0, "dev", 1) __title__ = "django-allauth" __version_info__ = VERSION From 34325fb46a8309512ef1c4f686ced3927be7c359 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 25 Apr 2024 12:17:31 +0100 Subject: [PATCH 4/4] Add new attribute that was misssing --- allauth/socialaccount/providers/xero/provider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/allauth/socialaccount/providers/xero/provider.py b/allauth/socialaccount/providers/xero/provider.py index e2fe710f75..17afcf6114 100644 --- a/allauth/socialaccount/providers/xero/provider.py +++ b/allauth/socialaccount/providers/xero/provider.py @@ -1,6 +1,9 @@ from allauth.account.models import EmailAddress from allauth.socialaccount.providers.base import ProviderAccount from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider +from allauth.socialaccount.providers.xero.views import ( + XeroOAuth2Adapter, +) class XeroAccount(ProviderAccount): @@ -25,6 +28,7 @@ class XeroProvider(OAuth2Provider): id = "xero" name = "Xero" account_class = XeroAccount + oauth2_adapter_class = XeroOAuth2Adapter def extract_uid(self, data): return str(data["xero_userid"])