providers/saml: configurable AuthnContextClassRef (#13566)
* providers/saml: make AuthnContextClassRef configurable Signed-off-by: Jens Langhammer <jens@goauthentik.io> * providers/saml: fix incorrect AuthInstant Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -180,6 +180,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
|||||||
"session_valid_not_on_or_after",
|
"session_valid_not_on_or_after",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
"name_id_mapping",
|
"name_id_mapping",
|
||||||
|
"authn_context_class_ref_mapping",
|
||||||
"digest_algorithm",
|
"digest_algorithm",
|
||||||
"signature_algorithm",
|
"signature_algorithm",
|
||||||
"signing_kp",
|
"signing_kp",
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -71,6 +71,20 @@ class SAMLProvider(Provider):
|
|||||||
"the NameIDPolicy of the incoming request will be considered"
|
"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(
|
assertion_valid_not_before = models.TextField(
|
||||||
default="minutes=-5",
|
default="minutes=-5",
|
||||||
@ -170,7 +184,6 @@ class SAMLProvider(Provider):
|
|||||||
def launch_url(self) -> str | None:
|
def launch_url(self) -> str | None:
|
||||||
"""Use IDP-Initiated SAML flow as launch URL"""
|
"""Use IDP-Initiated SAML flow as launch URL"""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
return reverse(
|
return reverse(
|
||||||
"authentik_providers_saml:sso-init",
|
"authentik_providers_saml:sso-init",
|
||||||
kwargs={"application_slug": self.application.slug},
|
kwargs={"application_slug": self.application.slug},
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""SAML Assertion generator"""
|
"""SAML Assertion generator"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from types import GeneratorType
|
from types import GeneratorType
|
||||||
|
|
||||||
@ -52,6 +53,7 @@ class AssertionProcessor:
|
|||||||
_assertion_id: str
|
_assertion_id: str
|
||||||
_response_id: str
|
_response_id: str
|
||||||
|
|
||||||
|
_auth_instant: str
|
||||||
_valid_not_before: str
|
_valid_not_before: str
|
||||||
_session_not_on_or_after: str
|
_session_not_on_or_after: str
|
||||||
_valid_not_on_or_after: str
|
_valid_not_on_or_after: str
|
||||||
@ -65,6 +67,11 @@ class AssertionProcessor:
|
|||||||
self._assertion_id = get_random_id()
|
self._assertion_id = get_random_id()
|
||||||
self._response_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(
|
self._valid_not_before = get_time_string(
|
||||||
timedelta_from_string(self.provider.assertion_valid_not_before)
|
timedelta_from_string(self.provider.assertion_valid_not_before)
|
||||||
)
|
)
|
||||||
@ -131,7 +138,7 @@ class AssertionProcessor:
|
|||||||
def get_assertion_auth_n_statement(self) -> Element:
|
def get_assertion_auth_n_statement(self) -> Element:
|
||||||
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
||||||
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
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(
|
auth_n_statement.attrib["SessionIndex"] = sha256(
|
||||||
self.http_request.session.session_key.encode("ascii")
|
self.http_request.session.session_key.encode("ascii")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@ -158,6 +165,28 @@ class AssertionProcessor:
|
|||||||
auth_n_context_class_ref.text = (
|
auth_n_context_class_ref.text = (
|
||||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorContract"
|
"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
|
return auth_n_statement
|
||||||
|
|
||||||
def get_assertion_conditions(self) -> Element:
|
def get_assertion_conditions(self) -> Element:
|
||||||
|
@ -294,6 +294,61 @@ class TestAuthNRequest(TestCase):
|
|||||||
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
|
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
|
||||||
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
|
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):
|
def test_request_attributes(self):
|
||||||
"""Test full SAML Request/Response flow, fully signed"""
|
"""Test full SAML Request/Response flow, fully signed"""
|
||||||
user = create_test_admin_user()
|
user = create_test_admin_user()
|
||||||
@ -321,8 +376,10 @@ class TestAuthNRequest(TestCase):
|
|||||||
request = request_proc.build_auth_n()
|
request = request_proc.build_auth_n()
|
||||||
|
|
||||||
# Create invalid PropertyMapping
|
# Create invalid PropertyMapping
|
||||||
scope = SAMLPropertyMapping.objects.create(name="test", saml_name="test", expression="q")
|
mapping = SAMLPropertyMapping.objects.create(
|
||||||
self.provider.property_mappings.add(scope)
|
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)
|
# To get an assertion we need a parsed request (parsed by provider)
|
||||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||||
@ -338,7 +395,7 @@ class TestAuthNRequest(TestCase):
|
|||||||
self.assertTrue(events.exists())
|
self.assertTrue(events.exists())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
events.first().context["message"],
|
events.first().context["message"],
|
||||||
"Failed to evaluate property-mapping: 'test'",
|
f"Failed to evaluate property-mapping: '{mapping.name}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_idp_initiated(self):
|
def test_idp_initiated(self):
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
"""Time utilities"""
|
"""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"""
|
"""Get Data formatted in SAML format"""
|
||||||
if delta is None:
|
if delta is None:
|
||||||
delta = datetime.timedelta()
|
delta = timedelta()
|
||||||
now = datetime.datetime.now()
|
if isinstance(delta, timedelta):
|
||||||
final = now + delta
|
final = now() + delta
|
||||||
|
else:
|
||||||
|
final = delta
|
||||||
return final.strftime("%Y-%m-%dT%H:%M:%SZ")
|
return final.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
@ -6462,6 +6462,11 @@
|
|||||||
"title": "NameID Property Mapping",
|
"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"
|
"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": {
|
"digest_algorithm": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
31
schema.yml
31
schema.yml
@ -22191,6 +22191,11 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: authn_context_class_ref_mapping
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
- in: query
|
- in: query
|
||||||
name: authorization_flow
|
name: authorization_flow
|
||||||
schema:
|
schema:
|
||||||
@ -25745,7 +25750,7 @@ paths:
|
|||||||
description: ''
|
description: ''
|
||||||
delete:
|
delete:
|
||||||
operationId: sources_all_destroy
|
operationId: sources_all_destroy
|
||||||
description: Source Viewset
|
description: Prevent deletion of built-in sources
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: slug
|
name: slug
|
||||||
@ -52228,6 +52233,14 @@ components:
|
|||||||
title: NameID Property Mapping
|
title: NameID Property Mapping
|
||||||
description: Configure how the NameID value will be created. When left empty,
|
description: Configure how the NameID value will be created. When left empty,
|
||||||
the NameIDPolicy of the incoming request will be considered
|
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:
|
digest_algorithm:
|
||||||
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
||||||
signature_algorithm:
|
signature_algorithm:
|
||||||
@ -55183,6 +55196,14 @@ components:
|
|||||||
title: NameID Property Mapping
|
title: NameID Property Mapping
|
||||||
description: Configure how the NameID value will be created. When left empty,
|
description: Configure how the NameID value will be created. When left empty,
|
||||||
the NameIDPolicy of the incoming request will be considered
|
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:
|
digest_algorithm:
|
||||||
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
||||||
signature_algorithm:
|
signature_algorithm:
|
||||||
@ -55348,6 +55369,14 @@ components:
|
|||||||
title: NameID Property Mapping
|
title: NameID Property Mapping
|
||||||
description: Configure how the NameID value will be created. When left empty,
|
description: Configure how the NameID value will be created. When left empty,
|
||||||
the NameIDPolicy of the incoming request will be considered
|
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:
|
digest_algorithm:
|
||||||
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
||||||
signature_algorithm:
|
signature_algorithm:
|
||||||
|
@ -245,6 +245,41 @@ export function renderForm(
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${msg("AuthnContextClassRef Property Mapping")}
|
||||||
|
name="authnContextClassRefMapping"
|
||||||
|
>
|
||||||
|
<ak-search-select
|
||||||
|
.fetchObjects=${async (query?: string): Promise<SAMLPropertyMapping[]> => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
</ak-search-select>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
${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.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
|
||||||
<ak-text-input
|
<ak-text-input
|
||||||
name="assertionValidNotBefore"
|
name="assertionValidNotBefore"
|
||||||
|
Reference in New Issue
Block a user