Skip to content

Commit

Permalink
feat(socialaccount): Added support for SAML 2.0
Browse files Browse the repository at this point in the history
Add support for SAML 2.0
  • Loading branch information
pennersr authored Aug 3, 2023
2 parents bd61beb + 9a14b11 commit 552bc94
Show file tree
Hide file tree
Showing 19 changed files with 877 additions and 4 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install xmlsec
run: sudo apt-get install -y xmlsec1 libxmlsec1-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down Expand Up @@ -77,6 +79,8 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install xmlsec
run: sudo apt-get install -y xmlsec1 libxmlsec1-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
2 changes: 2 additions & 0 deletions ChangeLog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Note worthy changes
trend over the years has been towards the simpler and more streamlined form
"email".

- Added support for SAML 2.0.


Security notice
---------------
Expand Down
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ Features
compatible* provider, many *OAuth 1.0/2.0* providers, as well as
custom protocols such as, for example, *Telegram* authentication.

**💼 Enterprise ready**
Supports SAML 2.0, which is often used in a B2B context.

**🕵️ Battle-tested**
The package has been out in the open since 2010. It is in use by many
commercial companies whose business depends on it and has hence been
Expand Down
13 changes: 12 additions & 1 deletion allauth/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@


@pytest.fixture
def user_factory(db, email_factory):
def user(user_factory):
return user_factory()


@pytest.fixture
def auth_client(client, user):
client.force_login(user)
return client


@pytest.fixture
def user_factory(email_factory, db):
def factory(
email=None, username=None, commit=True, with_email=True, email_verified=True
):
Expand Down
12 changes: 9 additions & 3 deletions allauth/socialaccount/providers/base/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ def sociallogin_from_response(self, request, response):
provider=self.app.provider_id or self.app.provider,
)
email_addresses = self.extract_email_addresses(response)
self.cleanup_email_addresses(common_fields.get("email"), email_addresses)
self.cleanup_email_addresses(
common_fields.get("email"),
email_addresses,
email_verified=common_fields.get("email_verified"),
)
sociallogin = SocialLogin(
account=socialaccount, email_addresses=email_addresses
)
Expand Down Expand Up @@ -118,10 +122,12 @@ def extract_common_fields(self, data):
"""
return {}

def cleanup_email_addresses(self, email, addresses):
def cleanup_email_addresses(self, email, addresses, email_verified=False):
# Move user.email over to EmailAddress
if email and email.lower() not in [a.email.lower() for a in addresses]:
addresses.append(EmailAddress(email=email, verified=False, primary=True))
addresses.append(
EmailAddress(email=email, verified=bool(email_verified), primary=True)
)
# Force verified emails
settings = self.get_settings()
verified_email = settings.get("VERIFIED_EMAIL", False)
Expand Down
Empty file.
177 changes: 177 additions & 0 deletions allauth/socialaccount/providers/saml/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import base64
from unittest.mock import patch

from django.test.client import Client

import pytest


@pytest.fixture
def client():
client = Client(HTTP_HOST="example.com")
return client


@pytest.fixture
def saml_settings(settings):
settings.SOCIALACCOUNT_PROVIDERS = {
"saml": {
"APPS": [
{
"client_id": "org",
"provider_id": "urn:dev-123.us.auth0.com",
"settings": {
"attribute_mapping": {
"uid": "http://schemas.auth0.com/clientID",
"email_verified": "http://schemas.auth0.com/email_verified",
"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
},
"idp": {
"name": "Test IdP",
"entity_id": "urn:dev-123.us.auth0.com",
"sso_url": "https://dev-123.us.auth0.com/samlp/456",
"slo_url": "https://dev-123.us.auth0.com/samlp/456",
"x509cert": "",
},
"advanced": {
"strict": False,
},
},
}
]
}
}


@pytest.fixture
def acs_saml_response():
xml = """<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="123" InResponseTo="ONELOGIN_456" Version="2.0" IssueInstant="2023-07-08T08:24:14.141Z" Destination="https://allauth.org/accounts/org/acs/">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:dev-123.us.auth0.com
</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="123" IssueInstant="2023-07-08T08:24:14.094Z">
<saml:Issuer>urn:dev-123.us.auth0.com
</saml:Issuer>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#123">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<DigestValue>123
</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>If7dFg...
</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>MIIDHTCC...
</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">google-oauth2|108204123456789
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="2023-07-08T09:24:14.094Z" Recipient="https://allauth.org/accounts/org/acs/" InResponseTo="ONELOGIN_f293b01d18bb0ac85a611b35e0c898af582bcfdd"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2023-07-08T08:24:14.094Z" NotOnOrAfter="2023-07-08T09:24:14.094Z">
<saml:AudienceRestriction>
<saml:Audience>https://allauth.org/accounts/org/metadata/
</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2023-07-08T08:24:14.094Z" SessionIndex="_qPrYdL0O8w3vdb8eCEY5ZtHe76LA8-JU">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">google-oauth2|108204123456789
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">[email protected]
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">John
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">John
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">[email protected]
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/identities/default/provider" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">google-oauth2
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/identities/default/connection" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">google-oauth2
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/identities/default/isSocial" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:boolean">true
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/clientID" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">dummysamluid
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/created_at" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:anyType">Wed Jun 28 2023 17:53:49 GMT+0000 (Coordinated Universal Time)
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/email_verified" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:boolean">true
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/locale" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">en
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/nickname" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">john.doe
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/picture" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">https://lh3.googleusercontent.com/a/AAcHTtfZ0fEyL3BKP1Hk2v1bNwpJd6ckIeo6jSExlkVjMXaIpsY=s96-c
</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.auth0.com/updated_at" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:anyType">Sat Jul 08 2023 06:13:07 GMT+0000 (Coordinated Universal Time)
</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
"""
return base64.b64encode(xml.encode("utf8")).decode("utf8")


@pytest.fixture
def sls_saml_request():
xml = "<dummy></dummy>"
return base64.b64encode(xml.encode("utf8")).decode("utf8")


@pytest.fixture
def mocked_signature_validation():
with patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign") as mock:
mock.return_value = True
yield
87 changes: 87 additions & 0 deletions allauth/socialaccount/providers/saml/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from django.urls import reverse
from django.utils.http import urlencode

from allauth.socialaccount.providers.base import Provider, ProviderAccount


class SAMLAccount(ProviderAccount):
def to_str(self):
return super().to_str()


class SAMLProvider(Provider):
id = "saml"
account_class = SAMLAccount
default_attribute_mapping = {
"uid": [
"http://schemas.auth0.com/clientID",
"urn:oasis:names:tc:SAML:attribute:subject-id",
],
"email": [
"urn:oid:0.9.2342.19200300.100.1.3",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
],
"email_verified": [
"http://schemas.auth0.com/email_verified",
],
"first_name": [
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
"urn:oid:2.5.4.42",
],
"last_name": [
"urn:oid:2.5.4.4",
],
"username": [
"http://schemas.auth0.com/nickname",
],
}

@property
def name(self):
return self.app.name or self.app.client_id or self.id

def get_login_url(self, request, **kwargs):
url = reverse("saml_login", kwargs={"organization_slug": self.app.client_id})
if kwargs:
url = url + "?" + urlencode(kwargs)
return url

def extract_extra_data(self, data):
return data.get_attributes()

def extract_uid(self, data):
"""
The `uid` is not unique across different SAML IdP's. Therefore,
we're using a fully qualified ID: <uid>@<entity_id>.
"""
return self._extract(data)["uid"]

def extract_common_fields(self, data):
ret = self._extract(data)
ret.pop("uid", None)
return ret

def _extract(self, data):
provider_config = self.app.settings
raw_attributes = data.get_attributes()
attributes = {}
attribute_mapping = provider_config.get(
"attribute_mapping", self.default_attribute_mapping
)
# map configured provider attributes
for key, provider_keys in attribute_mapping.items():
if isinstance(provider_keys, str):
provider_keys = [provider_keys]
for provider_key in provider_keys:
attribute_list = raw_attributes.get(provider_key, [""])
if len(attribute_list) > 0:
attributes[key] = attribute_list[0]
break
email_verified = attributes.get("email_verified")
if email_verified:
email_verified = email_verified.lower() in ["true", "1", "t", "y", "yes"]
attributes["email_verified"] = email_verified
return attributes


provider_classes = [SAMLProvider]
Loading

0 comments on commit 552bc94

Please sign in to comment.