Skip to content

Commit c1a2a6e

Browse files
committed
Add functionality to assert static SAML attributes per CO
Added functionality to the frontends.saml2.SAMLVirtualCoFrontend class so that the frontend SAML IdP can be configured to assert a set of SAML attributes with static values for all users per CO.
1 parent 86537fb commit c1a2a6e

File tree

3 files changed

+187
-38
lines changed

3 files changed

+187
-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: 40 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,25 @@ 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+
attributes[attribute] = value
746+
747+
# Handle the authentication response.
748+
return super()._handle_authn_response(context, internal_response, idp)
734749

735750
def _create_state_data(self, context, resp_args, relay_state):
736751
"""
@@ -747,6 +762,22 @@ def _create_state_data(self, context, resp_args, relay_state):
747762

748763
return state
749764

765+
def _get_co_config(self, context):
766+
"""
767+
Obtain the configuration for the CO.
768+
769+
:type context: The current context
770+
:rtype: dict
771+
772+
:param context: The current context
773+
:return: CO configuration
774+
775+
"""
776+
co_name = self._get_co_name(context)
777+
for co in self.config[self.KEY_CO]:
778+
if co[self.KEY_ENCODEABLE_NAME] == co_name:
779+
return co
780+
750781
def _get_co_name_from_path(self, context):
751782
"""
752783
The CO name is URL encoded and obtained from the request path
@@ -866,21 +897,19 @@ def _overlay_for_saml_metadata(self, config, co_name):
866897
867898
:return: config with updated details for SAML metadata
868899
"""
869-
for co in self.config[self.KEY_CO]:
870-
if co[self.KEY_ENCODEABLE_NAME] == co_name:
871-
break
900+
co_config = self._get_co_config(co_name)
872901

873902
key = self.KEY_ORGANIZATION
874-
if key in co:
903+
if key in co_config:
875904
if key not in config:
876905
config[key] = {}
877906
for org_key in self.KEY_ORGANIZATION_KEYS:
878-
if org_key in co[key]:
879-
config[key][org_key] = co[key][org_key]
907+
if org_key in co_config[key]:
908+
config[key][org_key] = co_config[key][org_key]
880909

881910
key = self.KEY_CONTACT_PERSON
882-
if key in co:
883-
config[key] = co[key]
911+
if key in co_config:
912+
config[key] = co_config[key]
884913

885914
return config
886915

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)