Skip to content

Commit 3da2298

Browse files
committed
Check that the response has all of the AuthnContexts that we provided in the request.
1 parent 62148cb commit 3da2298

File tree

7 files changed

+88
-1
lines changed

7 files changed

+88
-1
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,13 @@ In addition to the required settings data (idp, sp), extra settings can be defin
396396

397397
// Authentication context.
398398
// Set to false and no AuthContext will be sent in the AuthNRequest,
399-
// Set true or don't present thi parameter and you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
399+
// Set true or don't present this parameter and you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
400400
// Set an array with the possible auth context values: array ('urn:oasis:names:tc:SAML:2.0:ac:classes:Password', 'urn:oasis:names:tc:SAML:2.0:ac:classes:X509'),
401401
"requestedAuthnContext": true,
402402
// Allows the authn comparison parameter to be set, defaults to 'exact' if the setting is not present.
403403
"requestedAuthnContextComparison": "exact",
404+
// Set to true to check that the AuthnContext received matches the one requested.
405+
"failOnAuthnContextMismatch": false,
404406

405407
// In some environment you will need to set how long the published metadata of the Service Provider gonna be valid.
406408
// is possible to not set the 2 following parameters (or set to null) and default values will be set (2 days, 1 week)

src/onelogin/saml2/auth.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def __init__(self, request_data, old_settings=None, custom_base_path=None):
6363
self.__last_request_id = None
6464
self.__last_message_id = None
6565
self.__last_assertion_id = None
66+
self.__last_authn_contexts = []
6667
self.__last_request = None
6768
self.__last_response = None
6869
self.__last_assertion_not_on_or_after = None
@@ -110,6 +111,7 @@ def process_response(self, request_id=None):
110111
self.__session_expiration = response.get_session_not_on_or_after()
111112
self.__last_message_id = response.get_id()
112113
self.__last_assertion_id = response.get_assertion_id()
114+
self.__last_authn_contexts = response.get_authn_contexts()
113115
self.__authenticated = True
114116
self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after()
115117

@@ -318,6 +320,13 @@ def get_last_assertion_id(self):
318320
"""
319321
return self.__last_assertion_id
320322

323+
def get_last_authn_contexts(self):
324+
"""
325+
:returns: The list of authentication contexts sent in the last SAML resposne.
326+
:rtype: list
327+
"""
328+
return self.__last_authn_contexts
329+
321330
def login(self, return_to=None, force_authn=False, is_passive=False, set_nameid_policy=True):
322331
"""
323332
Initiates the SSO process.

src/onelogin/saml2/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class OneLogin_Saml2_ValidationError(Exception):
109109
INVALID_SIGNATURE = 42
110110
WRONG_NUMBER_OF_SIGNATURES = 43
111111
RESPONSE_EXPIRED = 44
112+
AUTHN_CONTEXT_MISMATCH = 45
112113

113114
def __init__(self, message, code=0, errors=None):
114115
"""

src/onelogin/saml2/response.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ def is_valid(self, request_data, request_id=None, raise_exceptions=False):
161161
OneLogin_Saml2_ValidationError.WRONG_NUMBER_OF_AUTHSTATEMENTS
162162
)
163163

164+
# Checks that the response has all of the AuthnContexts that we provided in the request.
165+
# Only check if failOnAuthnContextMismatch is true and requestedAuthnContext is set to a list.
166+
requested_authn_contexts = security['requestedAuthnContext']
167+
if security['failOnAuthnContextMismatch'] and requested_authn_contexts and requested_authn_contexts is not True:
168+
authn_contexts = self.get_authn_contexts()
169+
unmatched_contexts = set(requested_authn_contexts).difference(authn_contexts)
170+
if unmatched_contexts:
171+
raise OneLogin_Saml2_ValidationError(
172+
'The AuthnContext "%s" didn\'t include requested context "%s"' % (', '.join(authn_contexts), ', '.join(unmatched_contexts)),
173+
OneLogin_Saml2_ValidationError.AUTHN_CONTEXT_MISMATCH
174+
)
175+
164176
# Checks that there is at least one AttributeStatement if required
165177
attribute_statement_nodes = self.__query_assertion('/saml:AttributeStatement')
166178
if security.get('wantAttributeStatement', True) and not attribute_statement_nodes:
@@ -361,6 +373,16 @@ def get_audiences(self):
361373
audience_nodes = self.__query_assertion('/saml:Conditions/saml:AudienceRestriction/saml:Audience')
362374
return [OneLogin_Saml2_XML.element_text(node) for node in audience_nodes if OneLogin_Saml2_XML.element_text(node) is not None]
363375

376+
def get_authn_contexts(self):
377+
"""
378+
Gets the authentication contexts
379+
380+
:returns: The authentication classes for the SAML Response
381+
:rtype: list
382+
"""
383+
authn_context_nodes = self.__query_assertion('/saml:AuthnStatement/saml:AuthnContext/saml:AuthnContextClassRef')
384+
return [OneLogin_Saml2_XML.element_text(node) for node in authn_context_nodes]
385+
364386
def get_issuers(self):
365387
"""
366388
Gets the issuers (from message and from assertion)

src/onelogin/saml2/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ def __add_default_values(self):
310310
self.__sp.setdefault('privateKey', '')
311311

312312
self.__security.setdefault('requestedAuthnContext', True)
313+
self.__security.setdefault('failOnAuthnContextMismatch', False)
313314

314315
def check_settings(self, settings):
315316
"""

tests/src/OneLogin/saml2_tests/auth_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,20 @@ def testGetLastAuthnRequest(self):
12311231
)
12321232
self.assertIn(expectedFragment, auth.get_last_request_xml())
12331233

1234+
def testGetLastAuthnContexts(self):
1235+
settings = self.loadSettingsJSON()
1236+
request_data = self.get_request()
1237+
message = self.file_contents(
1238+
join(self.data_path, 'responses', 'valid_response.xml.base64'))
1239+
del request_data['get_data']
1240+
request_data['post_data'] = {
1241+
'SAMLResponse': message
1242+
}
1243+
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings)
1244+
1245+
auth.process_response()
1246+
self.assertEqual(auth.get_last_authn_contexts(), ['urn:oasis:names:tc:SAML:2.0:ac:classes:Password'])
1247+
12341248
def testGetLastLogoutRequest(self):
12351249
settings = self.loadSettingsJSON()
12361250
auth = OneLogin_Saml2_Auth({'http_host': 'localhost', 'script_name': 'thing'}, old_settings=settings)

tests/src/OneLogin/saml2_tests/response_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,44 @@ def testIsInValidAudience(self):
932932
self.assertFalse(response_2.is_valid(request_data))
933933
self.assertIn('is not a valid audience for this Response', response_2.get_error())
934934

935+
def testIsInValidAuthenticationContext(self):
936+
"""
937+
Tests that requestedAuthnContext, when set, is compared against the
938+
response AuthnContext, which is what you use for two-factor
939+
authentication. Without this check you can get back a valid response
940+
that didn't complete the two-factor step.
941+
"""
942+
request_data = self.get_request_data()
943+
message = self.file_contents(join(self.data_path, 'responses', 'valid_response.xml.base64'))
944+
two_factor_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken'
945+
password_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password'
946+
settings_dict = self.loadSettingsJSON()
947+
settings_dict['security']['requestedAuthnContext'] = [two_factor_context]
948+
settings_dict['security']['failOnAuthnContextMismatch'] = True
949+
settings_dict['strict'] = True
950+
settings = OneLogin_Saml2_Settings(settings_dict)
951+
952+
# check that we catch when the contexts don't match
953+
response = OneLogin_Saml2_Response(settings, message)
954+
self.assertFalse(response.is_valid(request_data))
955+
self.assertIn('The AuthnContext "%s" didn\'t include requested context "%s"' % (password_context, two_factor_context), response.get_error())
956+
957+
# now drop in the expected AuthnContextClassRef and see that it passes
958+
original_message = compat.to_string(OneLogin_Saml2_Utils.b64decode(message))
959+
two_factor_message = original_message.replace(password_context, two_factor_context)
960+
two_factor_message = OneLogin_Saml2_Utils.b64encode(two_factor_message)
961+
response = OneLogin_Saml2_Response(settings, two_factor_message)
962+
response.is_valid(request_data)
963+
# check that we got as far as destination validation, which comes later
964+
self.assertIn('The response was received at', response.get_error())
965+
966+
# with the default setting, check that we succeed with our original context
967+
settings_dict['security']['requestedAuthnContext'] = True
968+
settings = OneLogin_Saml2_Settings(settings_dict)
969+
response = OneLogin_Saml2_Response(settings, message)
970+
response.is_valid(request_data)
971+
self.assertIn('The response was received at', response.get_error())
972+
935973
def testIsInValidIssuer(self):
936974
"""
937975
Tests the is_valid method of the OneLogin_Saml2_Response class

0 commit comments

Comments
 (0)