Skip to content

Commit c93d857

Browse files
authored
providers/saml: configurable AuthnContextClassRef (#13566)
* providers/saml: make AuthnContextClassRef configurable Signed-off-by: Jens Langhammer <[email protected]> * providers/saml: fix incorrect AuthInstant Signed-off-by: Jens Langhammer <[email protected]> * add tests Signed-off-by: Jens Langhammer <[email protected]> --------- Signed-off-by: Jens Langhammer <[email protected]>
1 parent d163afe commit c93d857

File tree

9 files changed

+212
-11
lines changed

9 files changed

+212
-11
lines changed

authentik/providers/saml/api/providers.py

+1
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ class Meta:
180180
"session_valid_not_on_or_after",
181181
"property_mappings",
182182
"name_id_mapping",
183+
"authn_context_class_ref_mapping",
183184
"digest_algorithm",
184185
"signature_algorithm",
185186
"signing_kp",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.0.13 on 2025-03-18 17:41
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("authentik_providers_saml", "0016_samlprovider_encryption_kp_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="samlprovider",
16+
name="authn_context_class_ref_mapping",
17+
field=models.ForeignKey(
18+
blank=True,
19+
default=None,
20+
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.",
21+
null=True,
22+
on_delete=django.db.models.deletion.SET_DEFAULT,
23+
related_name="+",
24+
to="authentik_providers_saml.samlpropertymapping",
25+
verbose_name="AuthnContextClassRef Property Mapping",
26+
),
27+
),
28+
]

authentik/providers/saml/models.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ class SAMLProvider(Provider):
7171
"the NameIDPolicy of the incoming request will be considered"
7272
),
7373
)
74+
authn_context_class_ref_mapping = models.ForeignKey(
75+
"SAMLPropertyMapping",
76+
default=None,
77+
blank=True,
78+
null=True,
79+
on_delete=models.SET_DEFAULT,
80+
verbose_name=_("AuthnContextClassRef Property Mapping"),
81+
related_name="+",
82+
help_text=_(
83+
"Configure how the AuthnContextClassRef value will be created. When left empty, "
84+
"the AuthnContextClassRef will be set based on which authentication methods the user "
85+
"used to authenticate."
86+
),
87+
)
7488

7589
assertion_valid_not_before = models.TextField(
7690
default="minutes=-5",
@@ -170,7 +184,6 @@ class SAMLProvider(Provider):
170184
def launch_url(self) -> str | None:
171185
"""Use IDP-Initiated SAML flow as launch URL"""
172186
try:
173-
174187
return reverse(
175188
"authentik_providers_saml:sso-init",
176189
kwargs={"application_slug": self.application.slug},

authentik/providers/saml/processors/assertion.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""SAML Assertion generator"""
22

3+
from datetime import datetime
34
from hashlib import sha256
45
from types import GeneratorType
56

@@ -52,6 +53,7 @@ class AssertionProcessor:
5253
_assertion_id: str
5354
_response_id: str
5455

56+
_auth_instant: str
5557
_valid_not_before: str
5658
_session_not_on_or_after: str
5759
_valid_not_on_or_after: str
@@ -65,6 +67,11 @@ def __init__(self, provider: SAMLProvider, request: HttpRequest, auth_n_request:
6567
self._assertion_id = get_random_id()
6668
self._response_id = get_random_id()
6769

70+
_login_event = get_login_event(self.http_request)
71+
_login_time = datetime.now()
72+
if _login_event:
73+
_login_time = _login_event.created
74+
self._auth_instant = get_time_string(_login_time)
6875
self._valid_not_before = get_time_string(
6976
timedelta_from_string(self.provider.assertion_valid_not_before)
7077
)
@@ -131,7 +138,7 @@ def get_issuer(self) -> Element:
131138
def get_assertion_auth_n_statement(self) -> Element:
132139
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
133140
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
134-
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
141+
auth_n_statement.attrib["AuthnInstant"] = self._auth_instant
135142
auth_n_statement.attrib["SessionIndex"] = sha256(
136143
self.http_request.session.session_key.encode("ascii")
137144
).hexdigest()
@@ -158,6 +165,28 @@ def get_assertion_auth_n_statement(self) -> Element:
158165
auth_n_context_class_ref.text = (
159166
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorContract"
160167
)
168+
if self.provider.authn_context_class_ref_mapping:
169+
try:
170+
value = self.provider.authn_context_class_ref_mapping.evaluate(
171+
user=self.http_request.user,
172+
request=self.http_request,
173+
provider=self.provider,
174+
)
175+
if value is not None:
176+
auth_n_context_class_ref.text = str(value)
177+
return auth_n_statement
178+
except PropertyMappingExpressionException as exc:
179+
Event.new(
180+
EventAction.CONFIGURATION_ERROR,
181+
message=(
182+
"Failed to evaluate property-mapping: "
183+
f"'{self.provider.authn_context_class_ref_mapping.name}'"
184+
),
185+
provider=self.provider,
186+
mapping=self.provider.authn_context_class_ref_mapping,
187+
).from_http(self.http_request)
188+
LOGGER.warning("Failed to evaluate property mapping", exc=exc)
189+
return auth_n_statement
161190
return auth_n_statement
162191

163192
def get_assertion_conditions(self) -> Element:

authentik/providers/saml/tests/test_auth_n_request.py

+60-3
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,61 @@ def test_signed_static(self):
294294
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
295295
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
296296

297+
def test_authn_context_class_ref_mapping(self):
298+
"""Test custom authn_context_class_ref"""
299+
authn_context_class_ref = generate_id()
300+
mapping = SAMLPropertyMapping.objects.create(
301+
name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
302+
)
303+
self.provider.authn_context_class_ref_mapping = mapping
304+
self.provider.save()
305+
user = create_test_admin_user()
306+
http_request = get_request("/", user=user)
307+
308+
# First create an AuthNRequest
309+
request_proc = RequestProcessor(self.source, http_request, "test_state")
310+
request = request_proc.build_auth_n()
311+
312+
# To get an assertion we need a parsed request (parsed by provider)
313+
parsed_request = AuthNRequestParser(self.provider).parse(
314+
b64encode(request.encode()).decode(), "test_state"
315+
)
316+
# Now create a response and convert it to string (provider)
317+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
318+
response = response_proc.build_response()
319+
self.assertIn(user.username, response)
320+
self.assertIn(authn_context_class_ref, response)
321+
322+
def test_authn_context_class_ref_mapping_invalid(self):
323+
"""Test custom authn_context_class_ref (invalid)"""
324+
mapping = SAMLPropertyMapping.objects.create(name=generate_id(), expression="q")
325+
self.provider.authn_context_class_ref_mapping = mapping
326+
self.provider.save()
327+
user = create_test_admin_user()
328+
http_request = get_request("/", user=user)
329+
330+
# First create an AuthNRequest
331+
request_proc = RequestProcessor(self.source, http_request, "test_state")
332+
request = request_proc.build_auth_n()
333+
334+
# To get an assertion we need a parsed request (parsed by provider)
335+
parsed_request = AuthNRequestParser(self.provider).parse(
336+
b64encode(request.encode()).decode(), "test_state"
337+
)
338+
# Now create a response and convert it to string (provider)
339+
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
340+
response = response_proc.build_response()
341+
self.assertIn(user.username, response)
342+
343+
events = Event.objects.filter(
344+
action=EventAction.CONFIGURATION_ERROR,
345+
)
346+
self.assertTrue(events.exists())
347+
self.assertEqual(
348+
events.first().context["message"],
349+
f"Failed to evaluate property-mapping: '{mapping.name}'",
350+
)
351+
297352
def test_request_attributes(self):
298353
"""Test full SAML Request/Response flow, fully signed"""
299354
user = create_test_admin_user()
@@ -321,8 +376,10 @@ def test_request_attributes_invalid(self):
321376
request = request_proc.build_auth_n()
322377

323378
# Create invalid PropertyMapping
324-
scope = SAMLPropertyMapping.objects.create(name="test", saml_name="test", expression="q")
325-
self.provider.property_mappings.add(scope)
379+
mapping = SAMLPropertyMapping.objects.create(
380+
name=generate_id(), saml_name="test", expression="q"
381+
)
382+
self.provider.property_mappings.add(mapping)
326383

327384
# To get an assertion we need a parsed request (parsed by provider)
328385
parsed_request = AuthNRequestParser(self.provider).parse(
@@ -338,7 +395,7 @@ def test_request_attributes_invalid(self):
338395
self.assertTrue(events.exists())
339396
self.assertEqual(
340397
events.first().context["message"],
341-
"Failed to evaluate property-mapping: 'test'",
398+
f"Failed to evaluate property-mapping: '{mapping.name}'",
342399
)
343400

344401
def test_idp_initiated(self):
+9-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""Time utilities"""
22

3-
import datetime
3+
from datetime import datetime, timedelta
44

5+
from django.utils.timezone import now
56

6-
def get_time_string(delta: datetime.timedelta | None = None) -> str:
7+
8+
def get_time_string(delta: timedelta | datetime | None = None) -> str:
79
"""Get Data formatted in SAML format"""
810
if delta is None:
9-
delta = datetime.timedelta()
10-
now = datetime.datetime.now()
11-
final = now + delta
11+
delta = timedelta()
12+
if isinstance(delta, timedelta):
13+
final = now() + delta
14+
else:
15+
final = delta
1216
return final.strftime("%Y-%m-%dT%H:%M:%SZ")

blueprints/schema.json

+5
Original file line numberDiff line numberDiff line change
@@ -6462,6 +6462,11 @@
64626462
"title": "NameID Property Mapping",
64636463
"description": "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be considered"
64646464
},
6465+
"authn_context_class_ref_mapping": {
6466+
"type": "integer",
6467+
"title": "AuthnContextClassRef Property Mapping",
6468+
"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."
6469+
},
64656470
"digest_algorithm": {
64666471
"type": "string",
64676472
"enum": [

schema.yml

+30-1
Original file line numberDiff line numberDiff line change
@@ -22191,6 +22191,11 @@ paths:
2219122191
schema:
2219222192
type: string
2219322193
format: uuid
22194+
- in: query
22195+
name: authn_context_class_ref_mapping
22196+
schema:
22197+
type: string
22198+
format: uuid
2219422199
- in: query
2219522200
name: authorization_flow
2219622201
schema:
@@ -25745,7 +25750,7 @@ paths:
2574525750
description: ''
2574625751
delete:
2574725752
operationId: sources_all_destroy
25748-
description: Source Viewset
25753+
description: Prevent deletion of built-in sources
2574925754
parameters:
2575025755
- in: path
2575125756
name: slug
@@ -52228,6 +52233,14 @@ components:
5222852233
title: NameID Property Mapping
5222952234
description: Configure how the NameID value will be created. When left empty,
5223052235
the NameIDPolicy of the incoming request will be considered
52236+
authn_context_class_ref_mapping:
52237+
type: string
52238+
format: uuid
52239+
nullable: true
52240+
title: AuthnContextClassRef Property Mapping
52241+
description: Configure how the AuthnContextClassRef value will be created.
52242+
When left empty, the AuthnContextClassRef will be set based on which authentication
52243+
methods the user used to authenticate.
5223152244
digest_algorithm:
5223252245
$ref: '#/components/schemas/DigestAlgorithmEnum'
5223352246
signature_algorithm:
@@ -55183,6 +55196,14 @@ components:
5518355196
title: NameID Property Mapping
5518455197
description: Configure how the NameID value will be created. When left empty,
5518555198
the NameIDPolicy of the incoming request will be considered
55199+
authn_context_class_ref_mapping:
55200+
type: string
55201+
format: uuid
55202+
nullable: true
55203+
title: AuthnContextClassRef Property Mapping
55204+
description: Configure how the AuthnContextClassRef value will be created.
55205+
When left empty, the AuthnContextClassRef will be set based on which authentication
55206+
methods the user used to authenticate.
5518655207
digest_algorithm:
5518755208
$ref: '#/components/schemas/DigestAlgorithmEnum'
5518855209
signature_algorithm:
@@ -55348,6 +55369,14 @@ components:
5534855369
title: NameID Property Mapping
5534955370
description: Configure how the NameID value will be created. When left empty,
5535055371
the NameIDPolicy of the incoming request will be considered
55372+
authn_context_class_ref_mapping:
55373+
type: string
55374+
format: uuid
55375+
nullable: true
55376+
title: AuthnContextClassRef Property Mapping
55377+
description: Configure how the AuthnContextClassRef value will be created.
55378+
When left empty, the AuthnContextClassRef will be set based on which authentication
55379+
methods the user used to authenticate.
5535155380
digest_algorithm:
5535255381
$ref: '#/components/schemas/DigestAlgorithmEnum'
5535355382
signature_algorithm:

web/src/admin/providers/saml/SAMLProviderFormForm.ts

+35
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,41 @@ export function renderForm(
245245
)}
246246
</p>
247247
</ak-form-element-horizontal>
248+
<ak-form-element-horizontal
249+
label=${msg("AuthnContextClassRef Property Mapping")}
250+
name="authnContextClassRefMapping"
251+
>
252+
<ak-search-select
253+
.fetchObjects=${async (query?: string): Promise<SAMLPropertyMapping[]> => {
254+
const args: PropertymappingsProviderSamlListRequest = {
255+
ordering: "saml_name",
256+
};
257+
if (query !== undefined) {
258+
args.search = query;
259+
}
260+
const items = await new PropertymappingsApi(
261+
DEFAULT_CONFIG,
262+
).propertymappingsProviderSamlList(args);
263+
return items.results;
264+
}}
265+
.renderElement=${(item: SAMLPropertyMapping): string => {
266+
return item.name;
267+
}}
268+
.value=${(item: SAMLPropertyMapping | undefined): string | undefined => {
269+
return item?.pk;
270+
}}
271+
.selected=${(item: SAMLPropertyMapping): boolean => {
272+
return provider?.authnContextClassRefMapping === item.pk;
273+
}}
274+
?blankable=${true}
275+
>
276+
</ak-search-select>
277+
<p class="pf-c-form__helper-text">
278+
${msg(
279+
"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.",
280+
)}
281+
</p>
282+
</ak-form-element-horizontal>
248283
249284
<ak-text-input
250285
name="assertionValidNotBefore"

0 commit comments

Comments
 (0)