From c93d85731c8a647650288dda011ab5ea15401455 Mon Sep 17 00:00:00 2001
From: "Jens L."
Date: Wed, 19 Mar 2025 14:42:55 +0000
Subject: [PATCH] providers/saml: configurable AuthnContextClassRef (#13566)
* providers/saml: make AuthnContextClassRef configurable
Signed-off-by: Jens Langhammer
* providers/saml: fix incorrect AuthInstant
Signed-off-by: Jens Langhammer
* add tests
Signed-off-by: Jens Langhammer
---------
Signed-off-by: Jens Langhammer
---
authentik/providers/saml/api/providers.py | 1 +
...rovider_authn_context_class_ref_mapping.py | 28 +++++++++
authentik/providers/saml/models.py | 15 ++++-
.../providers/saml/processors/assertion.py | 31 ++++++++-
.../saml/tests/test_auth_n_request.py | 63 ++++++++++++++++++-
authentik/providers/saml/utils/time.py | 14 +++--
blueprints/schema.json | 5 ++
schema.yml | 31 ++++++++-
.../providers/saml/SAMLProviderFormForm.ts | 35 +++++++++++
9 files changed, 212 insertions(+), 11 deletions(-)
create mode 100644 authentik/providers/saml/migrations/0017_samlprovider_authn_context_class_ref_mapping.py
diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py
index c5c837ec42..9577ba8a8f 100644
--- a/authentik/providers/saml/api/providers.py
+++ b/authentik/providers/saml/api/providers.py
@@ -180,6 +180,7 @@ class SAMLProviderSerializer(ProviderSerializer):
"session_valid_not_on_or_after",
"property_mappings",
"name_id_mapping",
+ "authn_context_class_ref_mapping",
"digest_algorithm",
"signature_algorithm",
"signing_kp",
diff --git a/authentik/providers/saml/migrations/0017_samlprovider_authn_context_class_ref_mapping.py b/authentik/providers/saml/migrations/0017_samlprovider_authn_context_class_ref_mapping.py
new file mode 100644
index 0000000000..c86dcccfa7
--- /dev/null
+++ b/authentik/providers/saml/migrations/0017_samlprovider_authn_context_class_ref_mapping.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.13 on 2025-03-18 17:41
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_saml", "0016_samlprovider_encryption_kp_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="samlprovider",
+ name="authn_context_class_ref_mapping",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text="Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="+",
+ to="authentik_providers_saml.samlpropertymapping",
+ verbose_name="AuthnContextClassRef Property Mapping",
+ ),
+ ),
+ ]
diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py
index 928493b6d5..0325d40cd7 100644
--- a/authentik/providers/saml/models.py
+++ b/authentik/providers/saml/models.py
@@ -71,6 +71,20 @@ class SAMLProvider(Provider):
"the NameIDPolicy of the incoming request will be considered"
),
)
+ authn_context_class_ref_mapping = models.ForeignKey(
+ "SAMLPropertyMapping",
+ default=None,
+ blank=True,
+ null=True,
+ on_delete=models.SET_DEFAULT,
+ verbose_name=_("AuthnContextClassRef Property Mapping"),
+ related_name="+",
+ help_text=_(
+ "Configure how the AuthnContextClassRef value will be created. When left empty, "
+ "the AuthnContextClassRef will be set based on which authentication methods the user "
+ "used to authenticate."
+ ),
+ )
assertion_valid_not_before = models.TextField(
default="minutes=-5",
@@ -170,7 +184,6 @@ class SAMLProvider(Provider):
def launch_url(self) -> str | None:
"""Use IDP-Initiated SAML flow as launch URL"""
try:
-
return reverse(
"authentik_providers_saml:sso-init",
kwargs={"application_slug": self.application.slug},
diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py
index dd618f2800..c32b5f0c1c 100644
--- a/authentik/providers/saml/processors/assertion.py
+++ b/authentik/providers/saml/processors/assertion.py
@@ -1,5 +1,6 @@
"""SAML Assertion generator"""
+from datetime import datetime
from hashlib import sha256
from types import GeneratorType
@@ -52,6 +53,7 @@ class AssertionProcessor:
_assertion_id: str
_response_id: str
+ _auth_instant: str
_valid_not_before: str
_session_not_on_or_after: str
_valid_not_on_or_after: str
@@ -65,6 +67,11 @@ class AssertionProcessor:
self._assertion_id = get_random_id()
self._response_id = get_random_id()
+ _login_event = get_login_event(self.http_request)
+ _login_time = datetime.now()
+ if _login_event:
+ _login_time = _login_event.created
+ self._auth_instant = get_time_string(_login_time)
self._valid_not_before = get_time_string(
timedelta_from_string(self.provider.assertion_valid_not_before)
)
@@ -131,7 +138,7 @@ class AssertionProcessor:
def get_assertion_auth_n_statement(self) -> Element:
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
- auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
+ auth_n_statement.attrib["AuthnInstant"] = self._auth_instant
auth_n_statement.attrib["SessionIndex"] = sha256(
self.http_request.session.session_key.encode("ascii")
).hexdigest()
@@ -158,6 +165,28 @@ class AssertionProcessor:
auth_n_context_class_ref.text = (
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorContract"
)
+ if self.provider.authn_context_class_ref_mapping:
+ try:
+ value = self.provider.authn_context_class_ref_mapping.evaluate(
+ user=self.http_request.user,
+ request=self.http_request,
+ provider=self.provider,
+ )
+ if value is not None:
+ auth_n_context_class_ref.text = str(value)
+ return auth_n_statement
+ except PropertyMappingExpressionException as exc:
+ Event.new(
+ EventAction.CONFIGURATION_ERROR,
+ message=(
+ "Failed to evaluate property-mapping: "
+ f"'{self.provider.authn_context_class_ref_mapping.name}'"
+ ),
+ provider=self.provider,
+ mapping=self.provider.authn_context_class_ref_mapping,
+ ).from_http(self.http_request)
+ LOGGER.warning("Failed to evaluate property mapping", exc=exc)
+ return auth_n_statement
return auth_n_statement
def get_assertion_conditions(self) -> Element:
diff --git a/authentik/providers/saml/tests/test_auth_n_request.py b/authentik/providers/saml/tests/test_auth_n_request.py
index 48d6d713b6..499221c761 100644
--- a/authentik/providers/saml/tests/test_auth_n_request.py
+++ b/authentik/providers/saml/tests/test_auth_n_request.py
@@ -294,6 +294,61 @@ class TestAuthNRequest(TestCase):
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
+ def test_authn_context_class_ref_mapping(self):
+ """Test custom authn_context_class_ref"""
+ authn_context_class_ref = generate_id()
+ mapping = SAMLPropertyMapping.objects.create(
+ name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
+ )
+ self.provider.authn_context_class_ref_mapping = mapping
+ self.provider.save()
+ user = create_test_admin_user()
+ http_request = get_request("/", user=user)
+
+ # First create an AuthNRequest
+ request_proc = RequestProcessor(self.source, http_request, "test_state")
+ request = request_proc.build_auth_n()
+
+ # To get an assertion we need a parsed request (parsed by provider)
+ parsed_request = AuthNRequestParser(self.provider).parse(
+ b64encode(request.encode()).decode(), "test_state"
+ )
+ # Now create a response and convert it to string (provider)
+ response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
+ response = response_proc.build_response()
+ self.assertIn(user.username, response)
+ self.assertIn(authn_context_class_ref, response)
+
+ def test_authn_context_class_ref_mapping_invalid(self):
+ """Test custom authn_context_class_ref (invalid)"""
+ mapping = SAMLPropertyMapping.objects.create(name=generate_id(), expression="q")
+ self.provider.authn_context_class_ref_mapping = mapping
+ self.provider.save()
+ user = create_test_admin_user()
+ http_request = get_request("/", user=user)
+
+ # First create an AuthNRequest
+ request_proc = RequestProcessor(self.source, http_request, "test_state")
+ request = request_proc.build_auth_n()
+
+ # To get an assertion we need a parsed request (parsed by provider)
+ parsed_request = AuthNRequestParser(self.provider).parse(
+ b64encode(request.encode()).decode(), "test_state"
+ )
+ # Now create a response and convert it to string (provider)
+ response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
+ response = response_proc.build_response()
+ self.assertIn(user.username, response)
+
+ events = Event.objects.filter(
+ action=EventAction.CONFIGURATION_ERROR,
+ )
+ self.assertTrue(events.exists())
+ self.assertEqual(
+ events.first().context["message"],
+ f"Failed to evaluate property-mapping: '{mapping.name}'",
+ )
+
def test_request_attributes(self):
"""Test full SAML Request/Response flow, fully signed"""
user = create_test_admin_user()
@@ -321,8 +376,10 @@ class TestAuthNRequest(TestCase):
request = request_proc.build_auth_n()
# Create invalid PropertyMapping
- scope = SAMLPropertyMapping.objects.create(name="test", saml_name="test", expression="q")
- self.provider.property_mappings.add(scope)
+ mapping = SAMLPropertyMapping.objects.create(
+ name=generate_id(), saml_name="test", expression="q"
+ )
+ self.provider.property_mappings.add(mapping)
# To get an assertion we need a parsed request (parsed by provider)
parsed_request = AuthNRequestParser(self.provider).parse(
@@ -338,7 +395,7 @@ class TestAuthNRequest(TestCase):
self.assertTrue(events.exists())
self.assertEqual(
events.first().context["message"],
- "Failed to evaluate property-mapping: 'test'",
+ f"Failed to evaluate property-mapping: '{mapping.name}'",
)
def test_idp_initiated(self):
diff --git a/authentik/providers/saml/utils/time.py b/authentik/providers/saml/utils/time.py
index dda87d5ca8..8d5af313a9 100644
--- a/authentik/providers/saml/utils/time.py
+++ b/authentik/providers/saml/utils/time.py
@@ -1,12 +1,16 @@
"""Time utilities"""
-import datetime
+from datetime import datetime, timedelta
+
+from django.utils.timezone import now
-def get_time_string(delta: datetime.timedelta | None = None) -> str:
+def get_time_string(delta: timedelta | datetime | None = None) -> str:
"""Get Data formatted in SAML format"""
if delta is None:
- delta = datetime.timedelta()
- now = datetime.datetime.now()
- final = now + delta
+ delta = timedelta()
+ if isinstance(delta, timedelta):
+ final = now() + delta
+ else:
+ final = delta
return final.strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/blueprints/schema.json b/blueprints/schema.json
index c181b308df..8fcf08074d 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -6462,6 +6462,11 @@
"title": "NameID Property Mapping",
"description": "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be considered"
},
+ "authn_context_class_ref_mapping": {
+ "type": "integer",
+ "title": "AuthnContextClassRef Property Mapping",
+ "description": "Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate."
+ },
"digest_algorithm": {
"type": "string",
"enum": [
diff --git a/schema.yml b/schema.yml
index 1283656f05..26a2b6aabc 100644
--- a/schema.yml
+++ b/schema.yml
@@ -22191,6 +22191,11 @@ paths:
schema:
type: string
format: uuid
+ - in: query
+ name: authn_context_class_ref_mapping
+ schema:
+ type: string
+ format: uuid
- in: query
name: authorization_flow
schema:
@@ -25745,7 +25750,7 @@ paths:
description: ''
delete:
operationId: sources_all_destroy
- description: Source Viewset
+ description: Prevent deletion of built-in sources
parameters:
- in: path
name: slug
@@ -52228,6 +52233,14 @@ components:
title: NameID Property Mapping
description: Configure how the NameID value will be created. When left empty,
the NameIDPolicy of the incoming request will be considered
+ authn_context_class_ref_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: AuthnContextClassRef Property Mapping
+ description: Configure how the AuthnContextClassRef value will be created.
+ When left empty, the AuthnContextClassRef will be set based on which authentication
+ methods the user used to authenticate.
digest_algorithm:
$ref: '#/components/schemas/DigestAlgorithmEnum'
signature_algorithm:
@@ -55183,6 +55196,14 @@ components:
title: NameID Property Mapping
description: Configure how the NameID value will be created. When left empty,
the NameIDPolicy of the incoming request will be considered
+ authn_context_class_ref_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: AuthnContextClassRef Property Mapping
+ description: Configure how the AuthnContextClassRef value will be created.
+ When left empty, the AuthnContextClassRef will be set based on which authentication
+ methods the user used to authenticate.
digest_algorithm:
$ref: '#/components/schemas/DigestAlgorithmEnum'
signature_algorithm:
@@ -55348,6 +55369,14 @@ components:
title: NameID Property Mapping
description: Configure how the NameID value will be created. When left empty,
the NameIDPolicy of the incoming request will be considered
+ authn_context_class_ref_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: AuthnContextClassRef Property Mapping
+ description: Configure how the AuthnContextClassRef value will be created.
+ When left empty, the AuthnContextClassRef will be set based on which authentication
+ methods the user used to authenticate.
digest_algorithm:
$ref: '#/components/schemas/DigestAlgorithmEnum'
signature_algorithm:
diff --git a/web/src/admin/providers/saml/SAMLProviderFormForm.ts b/web/src/admin/providers/saml/SAMLProviderFormForm.ts
index 1652b10d90..b798cb40af 100644
--- a/web/src/admin/providers/saml/SAMLProviderFormForm.ts
+++ b/web/src/admin/providers/saml/SAMLProviderFormForm.ts
@@ -245,6 +245,41 @@ export function renderForm(
)}
+
+ => {
+ const args: PropertymappingsProviderSamlListRequest = {
+ ordering: "saml_name",
+ };
+ if (query !== undefined) {
+ args.search = query;
+ }
+ const items = await new PropertymappingsApi(
+ DEFAULT_CONFIG,
+ ).propertymappingsProviderSamlList(args);
+ return items.results;
+ }}
+ .renderElement=${(item: SAMLPropertyMapping): string => {
+ return item.name;
+ }}
+ .value=${(item: SAMLPropertyMapping | undefined): string | undefined => {
+ return item?.pk;
+ }}
+ .selected=${(item: SAMLPropertyMapping): boolean => {
+ return provider?.authnContextClassRefMapping === item.pk;
+ }}
+ ?blankable=${true}
+ >
+
+
+ ${msg(
+ "Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate.",
+ )}
+
+