Skip to content

Commit

Permalink
sources/saml: Basic support for EncryptedAssertion element. (#10099)
Browse files Browse the repository at this point in the history
* source/saml: Updated backend for encrypted assertion support

* source/saml: all lint-fix checks passed

* source/saml: Used Optional type instead of union, on enc_key_descriptor type hint

* source/saml: request_encrypted_assertion model field migration

* source/saml: Added 'noqa' comment to type hint on encryption key descriptor

* small fix

Signed-off-by: Jens Langhammer <[email protected]>

* add to UI

Signed-off-by: Jens Langhammer <[email protected]>

* add some error handling

Signed-off-by: Jens Langhammer <[email protected]>

* sources/saml: Pivot to encryption_kp model field, instead of request_encryption bool

* sources/saml: Typo fix

* re-create migrations

Signed-off-by: Jens Langhammer <[email protected]>

* update web

Signed-off-by: Jens Langhammer <[email protected]>

* add to release notes

Signed-off-by: Jens Langhammer <[email protected]>

* unrelated fix

Signed-off-by: Jens Langhammer <[email protected]>

* add improve error handling, add tests

Signed-off-by: Jens Langhammer <[email protected]>

* test metadata with encryption and remove WantAssertionsEncrypted since it's not in the schema

Signed-off-by: Jens Langhammer <[email protected]>

* unrelated fix to radius path

Signed-off-by: Jens Langhammer <[email protected]>

* fix unrelated fix...sigh

Signed-off-by: Jens Langhammer <[email protected]>

* re-migrate

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
Co-authored-by: Jens Langhammer <[email protected]>
  • Loading branch information
nicolas-semaphor and BeryJu authored Aug 7, 2024
1 parent 134caa9 commit 19c3f7d
Show file tree
Hide file tree
Showing 17 changed files with 310 additions and 11 deletions.
12 changes: 11 additions & 1 deletion authentik/providers/radius/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from base64 import b64encode

from django.conf import settings
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
Expand Down Expand Up @@ -100,7 +101,16 @@ def get_attributes(self, provider: RadiusProvider):
RadiusProviderPropertyMapping,
["packet"],
)
dict = Dictionary("authentik/providers/radius/dictionaries/dictionary")
dict = Dictionary(
str(
settings.BASE_DIR
/ "authentik"
/ "providers"
/ "radius"
/ "dictionaries"
/ "dictionary"
)
)

packet = AuthPacket()
packet.secret = provider.shared_secret
Expand Down
7 changes: 5 additions & 2 deletions authentik/providers/saml/processors/assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.time import get_time_string
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.saml.exceptions import UnsupportedNameIDFormat
from authentik.sources.saml.exceptions import InvalidSignature, UnsupportedNameIDFormat
from authentik.sources.saml.processors.constants import (
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
Expand Down Expand Up @@ -318,6 +318,9 @@ def build_response(self) -> str:
xmlsec.constants.KeyDataFormatCertPem,
)
ctx.key = key
ctx.sign(signature_node)
try:
ctx.sign(signature_node)
except xmlsec.Error as exc:
raise InvalidSignature() from exc

return etree.tostring(root_response).decode("utf-8") # nosec
1 change: 1 addition & 0 deletions authentik/sources/saml/api/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Meta:
"digest_algorithm",
"signature_algorithm",
"temporary_user_delete_after",
"encryption_kp",
]


Expand Down
4 changes: 4 additions & 0 deletions authentik/sources/saml/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@ class MismatchedRequestID(SAMLException):
"""Exception raised when the returned request ID doesn't match the saved ID."""


class InvalidEncryption(SAMLException):
"""Encryption of XML Object is either missing or invalid"""


class InvalidSignature(SAMLException):
"""Signature of XML Object is either missing or invalid"""
29 changes: 29 additions & 0 deletions authentik/sources/saml/migrations/0016_samlsource_encryption_kp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.0.8 on 2024-08-07 17:33

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
("authentik_sources_saml", "0015_groupsamlsourceconnection_samlsourcepropertymapping"),
]

operations = [
migrations.AddField(
model_name="samlsource",
name="encryption_kp",
field=models.ForeignKey(
blank=True,
default=None,
help_text="When selected, incoming assertions are encrypted by the IdP using the public key of the encryption keypair. The assertion is decrypted by the SP using the the private key.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="authentik_crypto.certificatekeypair",
verbose_name="Encryption Keypair",
),
),
]
14 changes: 14 additions & 0 deletions authentik/sources/saml/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,20 @@ class SAMLSource(Source):
on_delete=models.SET_NULL,
verbose_name=_("Signing Keypair"),
)
encryption_kp = models.ForeignKey(
CertificateKeyPair,
default=None,
null=True,
blank=True,
help_text=_(
"When selected, incoming assertions are encrypted by the IdP using the public "
"key of the encryption keypair. The assertion is decrypted by the SP using the "
"the private key."
),
on_delete=models.SET_NULL,
verbose_name=_("Encryption Keypair"),
related_name="+",
)

digest_algorithm = models.TextField(
choices=(
Expand Down
18 changes: 18 additions & 0 deletions authentik/sources/saml/processors/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ def get_signing_key_descriptor(self) -> Optional[Element]: # noqa: UP007
return key_descriptor
return None

def get_encryption_key_descriptor(self) -> Optional[Element]: # noqa: UP007
"""Get Encryption KeyDescriptor, if enabled for the source"""
if self.source.encryption_kp:
key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor")
key_descriptor.attrib["use"] = "encryption"
key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo")
x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data")
x509_certificate = SubElement(x509_data, f"{{{NS_SIGNATURE}}}X509Certificate")
x509_certificate.text = strip_pem_header(
self.source.encryption_kp.certificate_data.replace("\r", "")
).replace("\n", "")
return key_descriptor
return None

def get_name_id_formats(self) -> Iterator[Element]:
"""Get compatible NameID Formats"""
formats = [
Expand Down Expand Up @@ -74,6 +88,10 @@ def build_entity_descriptor(self) -> str:
if signing_descriptor is not None:
sp_sso_descriptor.append(signing_descriptor)

encryption_descriptor = self.get_encryption_key_descriptor()
if encryption_descriptor is not None:
sp_sso_descriptor.append(encryption_descriptor)

for name_id_format in self.get_name_id_formats():
sp_sso_descriptor.append(name_id_format)

Expand Down
39 changes: 36 additions & 3 deletions authentik/sources/saml/processors/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from authentik.core.sources.flow_manager import SourceFlowManager
from authentik.lib.utils.time import timedelta_from_string
from authentik.sources.saml.exceptions import (
InvalidEncryption,
InvalidSignature,
MismatchedRequestID,
MissingSAMLResponse,
Expand Down Expand Up @@ -76,11 +77,43 @@ def parse(self):
self._root_xml = b64decode(raw_response.encode())
self._root = fromstring(self._root_xml)

if self._source.encryption_kp:
self._decrypt_response()

if self._source.verification_kp:
self._verify_signed()
self._verify_request_id()
self._verify_status()

def _decrypt_response(self):
"""Decrypt SAMLResponse EncryptedAssertion Element"""
manager = xmlsec.KeysManager()
key = xmlsec.Key.from_memory(
self._source.encryption_kp.key_data,
xmlsec.constants.KeyDataFormatPem,
)

manager.add_key(key)
encryption_context = xmlsec.EncryptionContext(manager)

encrypted_assertion = self._root.find(f".//{{{NS_SAML_ASSERTION}}}EncryptedAssertion")
if encrypted_assertion is None:
raise InvalidEncryption()
encrypted_data = xmlsec.tree.find_child(
encrypted_assertion, "EncryptedData", xmlsec.constants.EncNs
)
try:
decrypted_assertion = encryption_context.decrypt(encrypted_data)
except xmlsec.Error as exc:
raise InvalidEncryption() from exc

index_of = self._root.index(encrypted_assertion)
self._root.remove(encrypted_assertion)
self._root.insert(
index_of,
decrypted_assertion,
)

def _verify_signed(self):
"""Verify SAML Response's Signature"""
signature_nodes = self._root.xpath(
Expand All @@ -101,9 +134,9 @@ def _verify_signed(self):
ctx.set_enabled_key_data([xmlsec.constants.KeyDataX509])
try:
ctx.verify(signature_node)
except (xmlsec.InternalError, xmlsec.VerificationError) as exc:
raise InvalidSignature from exc
LOGGER.debug("Successfully verified signautre")
except xmlsec.Error as exc:
raise InvalidSignature() from exc
LOGGER.debug("Successfully verified signature")

def _verify_request_id(self):
if self._source.allow_idp_initiated:
Expand Down
51 changes: 51 additions & 0 deletions authentik/sources/saml/tests/fixtures/encrypted-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----
42 changes: 42 additions & 0 deletions authentik/sources/saml/tests/fixtures/response_encrypted.xml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion authentik/sources/saml/tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def setUp(self):
slug=generate_id(),
issuer="authentik",
signing_kp=create_test_cert(),
encryption_kp=create_test_cert(),
pre_authentication_flow=create_test_flow(),
)

Expand All @@ -46,7 +47,7 @@ def test_metadata(self):
metadata = ElementTree.fromstring(xml)
self.assertEqual(metadata.attrib["entityID"], "authentik")

def test_metadata_without_signautre(self):
def test_metadata_without_signature(self):
"""Test Metadata generation being valid"""
self.source.signing_kp = None
self.source.save()
Expand Down
49 changes: 48 additions & 1 deletion authentik/sources/saml/tests/test_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import RequestFactory, TestCase

from authentik.core.tests.utils import create_test_flow
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response, load_fixture
from authentik.sources.saml.exceptions import InvalidEncryption
from authentik.sources.saml.models import SAMLSource
from authentik.sources.saml.processors.response import ResponseProcessor

Expand Down Expand Up @@ -77,3 +79,48 @@ def test_success(self):
"path": self.source.get_user_path(),
},
)

def test_encrypted_correct(self):
"""Test encrypted"""
key = load_fixture("fixtures/encrypted-key.pem")
kp = CertificateKeyPair.objects.create(
name=generate_id(),
key_data=key,
)
self.source.encryption_kp = kp
request = self.factory.post(
"/",
data={
"SAMLResponse": b64encode(
load_fixture("fixtures/response_encrypted.xml").encode()
).decode()
},
)

middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()

parser = ResponseProcessor(self.source, request)
parser.parse()

def test_encrypted_incorrect_key(self):
"""Test encrypted"""
kp = create_test_cert()
self.source.encryption_kp = kp
request = self.factory.post(
"/",
data={
"SAMLResponse": b64encode(
load_fixture("fixtures/response_encrypted.xml").encode()
).decode()
},
)

middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()

parser = ResponseProcessor(self.source, request)
with self.assertRaises(InvalidEncryption):
parser.parse()
6 changes: 6 additions & 0 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7407,6 +7407,12 @@
"minLength": 1,
"title": "Delete temporary users after",
"description": "Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. (Format: hours=1;minutes=2;seconds=3)."
},
"encryption_kp": {
"type": "string",
"format": "uuid",
"title": "Encryption Keypair",
"description": "When selected, incoming assertions are encrypted by the IdP using the public key of the encryption keypair. The assertion is decrypted by the SP using the the private key."
}
},
"required": []
Expand Down
Loading

0 comments on commit 19c3f7d

Please sign in to comment.