From d1a11dbfe6032c2bc8f1e96435b48c7faa6a203e Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Tue, 19 Oct 2021 15:46:58 +0300 Subject: Add new config option requested_authn_context Signed-off-by: Ivan Kanakarakis --- docs/howto/config.rst | 30 +++++++++++++++++++++++++++- src/saml2/client_base.py | 28 ++++++++++++++++++++++---- tests/servera_conf.py | 15 ++++++++++++-- tests/test_31_config.py | 45 ++++++++++++++++++++++++++++-------------- tests/test_71_authn_request.py | 25 +++++++++++++++-------- 5 files changed, 113 insertions(+), 30 deletions(-) diff --git a/docs/howto/config.rst b/docs/howto/config.rst index 9060ad2c..0cbfcbf1 100644 --- a/docs/howto/config.rst +++ b/docs/howto/config.rst @@ -342,7 +342,7 @@ ca_certs This is the path to a file containing root CA certificates for SSL server certificate validation. Example:: - + "ca_certs": full_path("cacerts.txt"), @@ -1222,6 +1222,34 @@ Example:: "requested_attribute_name_format": NAME_FORMAT_BASIC +requested_authn_context +""""""""""""""""""""""" + +This configuration option defines the ```` for an AuthnRequest by +a client. The value is a dictionary with two fields + +- ``authn_context_class_ref`` a list of string values representing + ```` elements. + +- ``comparison`` a string representing the Comparison xml-attribute value of the + ```` element. Per the SAML core specificiation the value should + be one of "exact", "minimum", "maximum", or "better". The default is "exact". + +Example:: + + "service": { + "sp": { + "requested_authn_context": { + "authn_context_class_ref": [ + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient", + ], + "comparison": "minimum", + } + } + } + + idp/aa/sp ^^^^^^^^^ diff --git a/src/saml2/client_base.py b/src/saml2/client_base.py index 90a4fbd1..4546ef07 100644 --- a/src/saml2/client_base.py +++ b/src/saml2/client_base.py @@ -17,7 +17,9 @@ from saml2.mdstore import locations from saml2.profile import paos, ecp from saml2.saml import NAMEID_FORMAT_PERSISTENT from saml2.saml import NAMEID_FORMAT_TRANSIENT -from saml2.samlp import AuthnQuery, RequestedAuthnContext +from saml2.saml import AuthnContextClassRef +from saml2.samlp import AuthnQuery +from saml2.samlp import RequestedAuthnContext from saml2.samlp import NameIDMappingRequest from saml2.samlp import AttributeQuery from saml2.samlp import AuthzDecisionQuery @@ -358,18 +360,36 @@ class Base(Entity): provider_name = self._my_name() args["provider_name"] = provider_name + requested_authn_context = ( + kwargs.pop("requested_authn_context", None) + or self.config.getattr("requested_authn_context", "sp") + or {} + ) + requested_authn_context_accrs = requested_authn_context.get( + "authn_context_class_ref", [] + ) + requested_authn_context_comparison = requested_authn_context.get( + "comparison", "exact" + ) + if requested_authn_context_accrs: + args["requested_authn_context"] = RequestedAuthnContext( + authn_context_class_ref=[ + AuthnContextClassRef(accr) + for accr in requested_authn_context_accrs + ], + comparison=requested_authn_context_comparison, + ) + # Allow argument values either as class instances or as dictionaries # all of these have cardinality 0..1 _msg = AuthnRequest() - for param in ["scoping", "requested_authn_context", "conditions", "subject"]: + for param in ["scoping", "conditions", "subject"]: _item = kwargs.pop(param, None) if not _item: continue if isinstance(_item, _msg.child_class(param)): args[param] = _item - elif isinstance(_item, dict): - args[param] = RequestedAuthnContext(**_item) else: raise ValueError("Wrong type for param {name}".format(name=param)) diff --git a/tests/servera_conf.py b/tests/servera_conf.py index 3ab8741b..5b837b34 100644 --- a/tests/servera_conf.py +++ b/tests/servera_conf.py @@ -4,6 +4,8 @@ from saml2 import BINDING_PAOS from saml2 import BINDING_HTTP_POST from saml2 import BINDING_HTTP_REDIRECT from saml2 import BINDING_HTTP_ARTIFACT +from saml2.authn_context import PASSWORDPROTECTEDTRANSPORT as AUTHN_PASSWORD_PROTECTED +from saml2.authn_context import TIMESYNCTOKEN as AUTHN_TIME_SYNC_TOKEN from saml2.saml import NAMEID_FORMAT_TRANSIENT from saml2.saml import NAMEID_FORMAT_PERSISTENT @@ -42,8 +44,17 @@ CONFIG = { "required_attributes": ["surName", "givenName", "mail"], "optional_attributes": ["title", "eduPersonAffiliation"], "idp": ["urn:mace:example.com:saml:roland:idp"], - "name_id_format": [NAMEID_FORMAT_TRANSIENT, - NAMEID_FORMAT_PERSISTENT] + "name_id_format": [ + NAMEID_FORMAT_TRANSIENT, + NAMEID_FORMAT_PERSISTENT, + ], + "requested_authn_context": { + "authn_context_class_ref": [ + AUTHN_PASSWORD_PROTECTED, + AUTHN_TIME_SYNC_TOKEN, + ], + "comparison": "exact", + }, } }, "debug": 1, diff --git a/tests/test_31_config.py b/tests/test_31_config.py index 9cf891e2..d58b9a01 100644 --- a/tests/test_31_config.py +++ b/tests/test_31_config.py @@ -5,15 +5,19 @@ import sys import logging from saml2.mdstore import MetadataStore, name -from saml2 import BINDING_HTTP_REDIRECT, BINDING_SOAP, BINDING_HTTP_POST -from saml2.config import SPConfig, IdPConfig, Config -from saml2.saml import AUTHN_PASSWORD_PROTECTED, AuthnContextClassRef -from saml2.samlp import RequestedAuthnContext +from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_SOAP +from saml2.config import Config +from saml2.config import IdPConfig +from saml2.config import SPConfig +from saml2.authn_context import PASSWORDPROTECTEDTRANSPORT as AUTHN_PASSWORD_PROTECTED +from saml2.authn_context import TIMESYNCTOKEN as AUTHN_TIME_SYNC_TOKEN from saml2 import logger from pathutils import dotname, full_path from saml2.sigver import security_context, CryptoBackendXMLSecurity + sp1 = { "entityid": "urn:mace:umu.se:saml:roland:sp", "service": { @@ -29,12 +33,13 @@ sp1 = { {'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': 'http://localhost:8088/sso/'}}, }, - "requested_authn_context": RequestedAuthnContext( - authn_context_class_ref=[ - AuthnContextClassRef(AUTHN_PASSWORD_PROTECTED), - ], - comparison="exact", - ), + "requested_authn_context": { + "authn_context_class_ref": [ + AUTHN_PASSWORD_PROTECTED, + AUTHN_TIME_SYNC_TOKEN, + ], + "comparison": "exact", + }, } }, "key_file": full_path("test.key"), @@ -218,13 +223,23 @@ def test_1(): assert len(c._sp_idp) == 1 assert list(c._sp_idp.keys()) == ["urn:mace:example.com:saml:roland:idp"] - assert list(c._sp_idp.values()) == [{'single_sign_on_service': - { - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': - 'http://localhost:8088/sso/'}}] + assert list(c._sp_idp.values()) == [ + { + 'single_sign_on_service': { + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': ( + 'http://localhost:8088/sso/' + ) + } + } + ] assert c.only_use_keys_in_metadata - assert 'PasswordProtectedTransport' in c._sp_requested_authn_context.to_string().decode() + assert type(c.getattr("requested_authn_context")) is dict + assert c.getattr("requested_authn_context").get("authn_context_class_ref") == [ + AUTHN_PASSWORD_PROTECTED, + AUTHN_TIME_SYNC_TOKEN, + ] + assert c.getattr("requested_authn_context").get("comparison") == "exact" def test_2(): diff --git a/tests/test_71_authn_request.py b/tests/test_71_authn_request.py index ee970923..6ee609e3 100644 --- a/tests/test_71_authn_request.py +++ b/tests/test_71_authn_request.py @@ -1,6 +1,7 @@ from contextlib import closing from saml2.client import Saml2Client from saml2.server import Server +from saml2.saml import AuthnContextClassRef def test_authn_request_with_acs_by_index(): @@ -15,22 +16,30 @@ def test_authn_request_with_acs_by_index(): # instead of AssertionConsumerServiceURL. The index with label ACS_INDEX # exists in the SP metadata in servera.xml. request_id, authn_request = sp.create_authn_request( - sp.config.entityid, - assertion_consumer_service_index=ACS_INDEX) + sp.config.entityid, assertion_consumer_service_index=ACS_INDEX + ) - # Make sure the authn_request contains AssertionConsumerServiceIndex. - acs_index = getattr(authn_request, - 'assertion_consumer_service_index', None) + assert authn_request.requested_authn_context.authn_context_class_ref == [ + AuthnContextClassRef(accr) + for accr in sp.config.getattr("requested_authn_context").get("authn_context_class_ref") + ] + assert authn_request.requested_authn_context.comparison == ( + sp.config.getattr("requested_authn_context").get("comparison") + ) + # Make sure the authn_request contains AssertionConsumerServiceIndex. + acs_index = getattr( + authn_request, 'assertion_consumer_service_index', None + ) assert acs_index == ACS_INDEX # Create IdP. with closing(Server(config_file="idp_all_conf")) as idp: - # Ask the IdP to pick out the binding and destination from the # authn_request. - binding, destination = idp.pick_binding("assertion_consumer_service", - request=authn_request) + binding, destination = idp.pick_binding( + "assertion_consumer_service", request=authn_request + ) # Make sure the IdP pick_binding method picks the correct location # or destination based on the ACS index in the authn request. -- cgit v1.2.1