diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py index c5c837ec426c..9577ba8a8fdf 100644 --- a/authentik/providers/saml/api/providers.py +++ b/authentik/providers/saml/api/providers.py @@ -180,6 +180,7 @@ class Meta: "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 000000000000..c86dcccfa7e9 --- /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 928493b6d52d..0325d40cd75f 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 dd618f2800ce..c32b5f0c1c9d 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 @@ def __init__(self, provider: SAMLProvider, request: HttpRequest, auth_n_request: 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 @@ def get_issuer(self) -> Element: 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 @@ def get_assertion_auth_n_statement(self) -> Element: 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 48d6d713b686..499221c76147 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 @@ def test_signed_static(self): 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 @@ def test_request_attributes_invalid(self): 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 @@ def test_request_attributes_invalid(self): 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 dda87d5ca87d..8d5af313a914 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 c181b308df42..8fcf08074d29 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 1283656f055b..26a2b6aabcdc 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 1652b10d900f..b798cb40af0d 100644 --- a/web/src/admin/providers/saml/SAMLProviderFormForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderFormForm.ts @@ -245,6 +245,41 @@ export function renderForm( )} </p> </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 name="assertionValidNotBefore"