Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Andrew Chen Wang
Andrey Akolpakov
Andrey Balandin
Andy Matthews
Andy Miller
Ani Vera
Anna Sirota
Antonin Delpeuch
Expand Down
2 changes: 1 addition & 1 deletion ChangeLog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion allauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

"""

VERSION = (0, 62, 0, "dev", 0)
VERSION = (0, 62, 0, "dev", 1)

__title__ = "django-allauth"
__version_info__ = VERSION
Expand Down
Empty file.
54 changes: 54 additions & 0 deletions allauth/socialaccount/providers/xero/provider.py
Original file line number Diff line number Diff line change
@@ -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]
151 changes: 151 additions & 0 deletions allauth/socialaccount/providers/xero/tests.py
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 6 additions & 0 deletions allauth/socialaccount/providers/xero/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns

from .provider import XeroProvider


urlpatterns = default_urlpatterns(XeroProvider)
74 changes: 74 additions & 0 deletions allauth/socialaccount/providers/xero/views.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions docs/installation/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions docs/socialaccount/providers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Provider Specifics
weixin
windowslive
xing
xero
yahoo
yandex
ynab
Expand Down
25 changes: 25 additions & 0 deletions docs/socialaccount/providers/xero.rst
Original file line number Diff line number Diff line change
@@ -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",
}
}
}
}

1 change: 1 addition & 0 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down