Skip to content

Commit 79abc46

Browse files
Merge pull request #233 from skoranda/saml_co_frontend_02
Add functionality to assert static SAML attributes per CO
2 parents 86537fb + 4560a40 commit 79abc46

File tree

3 files changed

+195
-38
lines changed

3 files changed

+195
-38
lines changed

example/plugins/frontends/saml2_virtualcofrontend.yaml.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ config:
2424
- contact_type: technical
2525
email_address: [email protected]
2626
given_name MESS Technical Support
27+
# SAML attributes and static values about the CO to be asserted for each user.
28+
# The key is the SATOSA internal attribute name.
29+
co_static_saml_attributes:
30+
organization: Medium Engergy Synchrotron Source
31+
countryname: US
32+
friendlycountryname: United States
33+
noreduorgacronym:
34+
- MESS
35+
- MeSyncS
2736
- encodeable_name: MTS
2837
organization:
2938
display_name: Milwaukee Theological Seminary

src/satosa/frontends/saml2.py

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ class SAMLVirtualCoFrontend(SAMLFrontend):
697697
"""
698698
KEY_CO = 'collaborative_organizations'
699699
KEY_CO_NAME = 'co_name'
700+
KEY_CO_ATTRIBUTES = 'co_static_saml_attributes'
700701
KEY_CONTACT_PERSON = 'contact_person'
701702
KEY_ENCODEABLE_NAME = 'encodeable_name'
702703
KEY_ORGANIZATION = 'organization'
@@ -726,11 +727,33 @@ def handle_authn_response(self, context, internal_response):
726727
:return:
727728
"""
728729

730+
return self._handle_authn_response(context, internal_response)
731+
732+
def _handle_authn_response(self, context, internal_response):
733+
"""
734+
"""
729735
# Using the context of the current request and saved state from the
730-
# authentication request dynamically create an IdP instance and then
731-
# use it to handle the authentication response.
736+
# authentication request dynamically create an IdP instance.
732737
idp = self._create_co_virtual_idp(context)
733-
return self._handle_authn_response(context, internal_response, idp)
738+
739+
# Add any static attributes for the CO.
740+
co_config = self._get_co_config(context)
741+
742+
if self.KEY_CO_ATTRIBUTES in co_config:
743+
attributes = internal_response.attributes
744+
for attribute, value in co_config[self.KEY_CO_ATTRIBUTES].items():
745+
# XXX This should be refactored when Python 3.4 support is
746+
# XXX no longer required to use isinstance(value, Iterable).
747+
try:
748+
if iter(value) and not isinstance(value, str):
749+
attributes[attribute] = value
750+
else:
751+
attributes[attribute] = [value]
752+
except TypeError:
753+
attributes[attribute] = [value]
754+
755+
# Handle the authentication response.
756+
return super()._handle_authn_response(context, internal_response, idp)
734757

735758
def _create_state_data(self, context, resp_args, relay_state):
736759
"""
@@ -747,6 +770,22 @@ def _create_state_data(self, context, resp_args, relay_state):
747770

748771
return state
749772

773+
def _get_co_config(self, context):
774+
"""
775+
Obtain the configuration for the CO.
776+
777+
:type context: The current context
778+
:rtype: dict
779+
780+
:param context: The current context
781+
:return: CO configuration
782+
783+
"""
784+
co_name = self._get_co_name(context)
785+
for co in self.config[self.KEY_CO]:
786+
if co[self.KEY_ENCODEABLE_NAME] == co_name:
787+
return co
788+
750789
def _get_co_name_from_path(self, context):
751790
"""
752791
The CO name is URL encoded and obtained from the request path
@@ -866,21 +905,19 @@ def _overlay_for_saml_metadata(self, config, co_name):
866905
867906
:return: config with updated details for SAML metadata
868907
"""
869-
for co in self.config[self.KEY_CO]:
870-
if co[self.KEY_ENCODEABLE_NAME] == co_name:
871-
break
908+
co_config = self._get_co_config(co_name)
872909

873910
key = self.KEY_ORGANIZATION
874-
if key in co:
911+
if key in co_config:
875912
if key not in config:
876913
config[key] = {}
877914
for org_key in self.KEY_ORGANIZATION_KEYS:
878-
if org_key in co[key]:
879-
config[key][org_key] = co[key][org_key]
915+
if org_key in co_config[key]:
916+
config[key][org_key] = co_config[key][org_key]
880917

881918
key = self.KEY_CONTACT_PERSON
882-
if key in co:
883-
config[key] = co[key]
919+
if key in co_config:
920+
config[key] = co_config[key]
884921

885922
return config
886923

tests/satosa/frontends/test_saml2.py

Lines changed: 138 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -405,46 +405,98 @@ def test_load_idp_dynamic_entity_id(self, idp_conf):
405405
assert idp.config.entityid == "{}/{}".format(idp_conf["entityid"], self.TARGET_ENTITY_ID)
406406

407407

408-
class TestSAMLVirtualCoFrontend:
408+
class TestSAMLVirtualCoFrontend(TestSAMLFrontend):
409409
BACKEND = "test_backend"
410410
CO = "MESS"
411+
CO_O = "organization"
412+
CO_C = "countryname"
413+
CO_CO = "friendlycountryname"
414+
CO_NOREDUORGACRONYM = "noreduorgacronym"
415+
CO_STATIC_SAML_ATTRIBUTES = {
416+
CO_O: ["Medium Energy Synchrotron Source"],
417+
CO_C: ["US"],
418+
CO_CO: ["United States"],
419+
CO_NOREDUORGACRONYM: ["MESS"]
420+
}
411421
KEY_SSO = "single_sign_on_service"
412422

413-
@pytest.fixture(autouse=True)
414-
def create_frontend(self, idp_conf):
415-
collab_orgs = [{"encodeable_name": self.CO}]
423+
@pytest.fixture
424+
def frontend(self, idp_conf, sp_conf):
425+
"""
426+
This fixture is an instance of the SAMLVirtualCoFrontend with an IdP
427+
configuration that includes SAML metadata for the test SP configured
428+
by the sp_conf fixture so that we can test a SAML Response sent
429+
from the IdP.
430+
"""
431+
# Use a utility function to serialize the sp_conf fixture as
432+
# a string and then dynamically add it as the metadata available
433+
# as part of the idp_conf fixture.
434+
sp_metadata_str = create_metadata_from_config_dict(sp_conf)
435+
idp_conf["metadata"]["inline"] = [sp_metadata_str]
436+
437+
# Dynamically add configuration details for the CO including static
438+
# SAML attributes so their presence in a SAML Response can be tested.
439+
collab_org = {
440+
"encodeable_name": self.CO,
441+
"co_static_saml_attributes": self.CO_STATIC_SAML_ATTRIBUTES
442+
}
443+
444+
# Use the dynamically updated idp_conf fixture, the configured
445+
# endpoints, and the collaborative organization configuration to
446+
# create the configuration for the frontend.
416447
conf = {
417448
"idp_config": idp_conf,
418449
"endpoints": ENDPOINTS,
419-
"collaborative_organizations": collab_orgs
450+
"collaborative_organizations": [collab_org]
420451
}
421-
self.frontend = SAMLVirtualCoFrontend(lambda ctx, req: None,
422-
INTERNAL_ATTRIBUTES,
423-
conf,
424-
BASE_URL,
425-
"saml_virtual_co_frontend")
426-
self.frontend.register_endpoints([self.BACKEND])
427452

428-
@pytest.fixture(autouse=True)
429-
def create_context(self, context):
453+
# Use a richer set of internal attributes than what is provided
454+
# for the parent class so that we can test for the static SAML
455+
# attributes about the CO being asserted.
456+
internal_attributes = INTERNAL_ATTRIBUTES
457+
internal_attributes["attributes"][self.CO_O] = {"saml": ["o"]}
458+
internal_attributes["attributes"][self.CO_C] = {"saml": ["c"]}
459+
internal_attributes["attributes"][self.CO_CO] = {"saml": ["co"]}
460+
internal_attributes["attributes"][self.CO_NOREDUORGACRONYM] = (
461+
{"saml": ["norEduOrgAcronym"]})
462+
463+
# Create, register the endpoints, and then return the frontend
464+
# instance.
465+
frontend = SAMLVirtualCoFrontend(lambda ctx, req: None,
466+
internal_attributes,
467+
conf,
468+
BASE_URL,
469+
"saml_virtual_co_frontend")
470+
frontend.register_endpoints([self.BACKEND])
471+
472+
return frontend
473+
474+
@pytest.fixture
475+
def context(self, context):
476+
"""
477+
This fixture is an instance of the context that mocks up the context
478+
that would be available during a SAML flow and that would include
479+
a path and target_backend that indicates the CO.
480+
"""
430481
context.path = "{}/{}/sso/redirect".format(self.BACKEND, self.CO)
431482
context.target_backend = self.BACKEND
432-
self.context = context
433483

434-
def test_create_state_data(self):
435-
self.context.decorate(self.frontend.KEY_CO_NAME, self.CO)
436-
state = self.frontend._create_state_data(self.context, {}, "")
437-
assert state[self.frontend.KEY_CO_NAME] == self.CO
484+
return context
485+
486+
def test_create_state_data(self, frontend, context):
487+
context.decorate(frontend.KEY_CO_NAME, self.CO)
488+
state = frontend._create_state_data(context, {}, "")
489+
assert state[frontend.KEY_CO_NAME] == self.CO
438490

439-
def test_get_co_name(self):
440-
co_name = self.frontend._get_co_name(self.context)
491+
def test_get_co_name(self, frontend, context):
492+
co_name = frontend._get_co_name(context)
441493
assert co_name == self.CO
442494

443-
self.frontend._create_state_data(self.context, {}, "")
444-
co_name = self.frontend._get_co_name(self.context)
495+
frontend._create_state_data(context, {}, "")
496+
co_name = frontend._get_co_name(context)
445497
assert co_name == self.CO
446498

447-
def test_create_co_virtual_idp(self, idp_conf):
499+
def test_create_co_virtual_idp(self, frontend, context, idp_conf):
448500
expected_entityid = "{}/{}".format(idp_conf['entityid'], self.CO)
449501

450502
endpoint_base_url = "{}/{}/{}".format(BASE_URL, self.BACKEND, self.CO)
@@ -453,15 +505,15 @@ def test_create_co_virtual_idp(self, idp_conf):
453505
endp = "{}/{}".format(endpoint_base_url, endpoint)
454506
expected_endpoints.append((endp, binding))
455507

456-
idp_server = self.frontend._create_co_virtual_idp(self.context)
508+
idp_server = frontend._create_co_virtual_idp(context)
457509
sso_endpoints = idp_server.config._idp_endpoints[self.KEY_SSO]
458510

459511
assert idp_server.config.entityid == expected_entityid
460512
assert all(sso in sso_endpoints for sso in expected_endpoints)
461513

462-
def test_register_endpoints(self):
463-
idp_server = self.frontend._create_co_virtual_idp(self.context)
464-
url_map = self.frontend.register_endpoints([self.BACKEND])
514+
def test_register_endpoints(self, frontend, context):
515+
idp_server = frontend._create_co_virtual_idp(context)
516+
url_map = frontend.register_endpoints([self.BACKEND])
465517
all_idp_endpoints = [urlparse(endpoint[0]).path[1:] for
466518
endpoint in
467519
idp_server.config._idp_endpoints[self.KEY_SSO]]
@@ -470,6 +522,65 @@ def test_register_endpoints(self):
470522
for endpoint in all_idp_endpoints:
471523
assert any(pat.match(endpoint) for pat in compiled_regex)
472524

525+
def test_co_static_attributes(self, frontend, context, internal_response,
526+
idp_conf, sp_conf):
527+
# Use the frontend and context fixtures to dynamically create the
528+
# proxy IdP server that would be created during a flow.
529+
idp_server = frontend._create_co_virtual_idp(context)
530+
531+
# Use the context fixture to find the CO name and the backend name
532+
# and then use those to dynamically update the ipd_conf fixture.
533+
co_name = frontend._get_co_name(context)
534+
backend_name = context.target_backend
535+
idp_conf = frontend._add_endpoints_to_config(idp_conf, co_name,
536+
backend_name)
537+
idp_conf = frontend._add_entity_id(idp_conf, co_name)
538+
539+
# Use a utility function to serialize the idp_conf IdP configuration
540+
# fixture to a string and then dynamically update the sp_conf
541+
# SP configuration fixture with the metadata.
542+
idp_metadata_str = create_metadata_from_config_dict(idp_conf)
543+
sp_conf["metadata"]["inline"].append(idp_metadata_str)
544+
sp_config = SPConfig().load(sp_conf, metadata_construction=False)
545+
546+
# Use the updated sp_config fixture to generate a fake SP and then
547+
# use the fake SP to generate an authentication request aimed at the
548+
# proxy CO virtual IdP.
549+
fakesp = FakeSP(sp_config)
550+
destination, auth_req = fakesp.make_auth_req(
551+
idp_server.config.entityid,
552+
nameid_format=None,
553+
relay_state="relay_state",
554+
subject=None,
555+
)
556+
557+
# Update the context with the authentication request.
558+
context.request = auth_req
559+
560+
# Create the response arguments necessary for the IdP to respond to
561+
# the authentication request, update the request state and with it
562+
# the context, and then use the frontend fixture and the
563+
# internal_response fixture to handle the authentication response
564+
# and generate a response from the proxy IdP to the SP.
565+
resp_args = {
566+
"name_id_policy": NameIDPolicy(format=NAMEID_FORMAT_TRANSIENT),
567+
"in_response_to": None,
568+
"destination": sp_config.endpoint(
569+
"assertion_consumer_service",
570+
binding=BINDING_HTTP_REDIRECT
571+
)[0],
572+
"sp_entity_id": sp_conf["entityid"],
573+
"binding": BINDING_HTTP_REDIRECT
574+
}
575+
request_state = frontend._create_state_data(context, resp_args, "")
576+
context.state[frontend.name] = request_state
577+
frontend.handle_authn_response(context, internal_response)
578+
579+
# Verify that the frontend added the CO static SAML attributes to the
580+
# internal response.
581+
for attr, value in self.CO_STATIC_SAML_ATTRIBUTES.items():
582+
assert internal_response.attributes[attr] == value
583+
473584

474585
class TestSubjectTypeToSamlNameIdFormat:
475586
def test_should_default_to_persistent(self):

0 commit comments

Comments
 (0)