diff --git a/AUTHORS b/AUTHORS index 9a0d08a7e0..0e897228a4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,6 +22,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 baac5e84f9..b0e49a29d1 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -6,7 +6,7 @@ Note worthy changes - Added a dummy provider, useful for testing purposes: ``allauth.socialaccount.providers.dummy``. -- Added a new provider, Atlassian +- Added a new providers, Atlassian, Xero - Next URL handling been streamlined to be consistently applied. Previously, the password reset, change and email confirmation views only supported the 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 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..17afcf6114 --- /dev/null +++ b/allauth/socialaccount/providers/xero/provider.py @@ -0,0 +1,54 @@ +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): + 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 + oauth2_adapter_class = XeroOAuth2Adapter + + 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) diff --git a/docs/installation/quickstart.rst b/docs/installation/quickstart.rst index 95802a2f36..130958fcfb 100644 --- a/docs/installation/quickstart.rst +++ b/docs/installation/quickstart.rst @@ -161,6 +161,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 19ea2bbd0c..b042395c38 100644 --- a/docs/socialaccount/providers/index.rst +++ b/docs/socialaccount/providers/index.rst @@ -134,6 +134,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 4b61045e2e..7cd60f8e17 100644 --- a/test_settings.py +++ b/test_settings.py @@ -188,6 +188,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",