Skip to content

Commit d56982a

Browse files
Merge pull request #369 from vkadam/co-entity-metadata-endpoint
Expose metadata endpoint and fix duplicate entity ids with multiple backends for VirtualCoFrontend
2 parents 8a096d5 + c35a20b commit d56982a

File tree

7 files changed

+129
-33
lines changed

7 files changed

+129
-33
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
**/.DS_Store
22
_build
33
.idea
4+
*.iml
45
*.pyc
56
*.log*
67

@@ -13,3 +14,4 @@ _build
1314
build/
1415
dist/
1516
.coverage
17+
venv/

example/internal_attributes.yaml.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ attributes:
2727
orcid: [emails.str]
2828
github: [email]
2929
openid: [email]
30-
saml: [email, emailAdress, mail]
30+
saml: [email, emailAddress, mail]
3131
name:
3232
facebook: [name]
3333
orcid: [name.credit-name]

example/plugins/frontends/saml2_virtualcofrontend.yaml.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ config:
4949
metadata:
5050
local: [sp.xml]
5151

52-
entityid: <base_url>/<name>/proxy.xml
52+
# Available placeholders to use while constructing entityid,
53+
# <backend_name>: Backend name
54+
# <co_name>: collaborative_organizations encodeable_name
55+
# <base_url>: Base url of installation
56+
# <name>: Name of this virtual co-frontend
57+
entityid: <base_url>/<backend_name>/idp/<co_name>
5358
accepted_time_diff: 60
5459
service:
5560
idp:

src/satosa/backends/saml2.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ def _metadata_endpoint(self, context):
448448
:param context: The current context
449449
:return: response with metadata
450450
"""
451-
msg = "Sending metadata response"
451+
msg = "Sending metadata response for entityId = {}".format(self.sp.config.entityid)
452452
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
453453
logger.debug(logline)
454454

@@ -488,6 +488,7 @@ def register_endpoints(self):
488488
("^%s$" % parsed_endp.path[1:], self.disco_response))
489489

490490
if self.expose_entityid_endpoint():
491+
logger.debug("Exposing backend entity endpoint = {}".format(self.sp.config.entityid))
491492
parsed_entity_id = urlparse(self.sp.config.entityid)
492493
url_map.append(("^{0}".format(parsed_entity_id.path[1:]),
493494
self._metadata_endpoint))

src/satosa/frontends/saml2.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ def _metadata_endpoint(self, context):
483483
:param context: The current context
484484
:return: response with metadata
485485
"""
486-
msg = "Sending metadata response"
486+
msg = "Sending metadata response for entityId = {}".format(self.idp.config.entityid)
487487
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
488488
logger.debug(logline)
489489
metadata_string = create_metadata_string(None, self.idp.config, 4, None, None, None, None,
@@ -523,6 +523,7 @@ def _register_endpoints(self, providers):
523523
functools.partial(self.handle_authn_request, binding_in=binding)))
524524

525525
if self.expose_entityid_endpoint():
526+
logger.debug("Exposing frontend entity endpoint = {}".format(self.idp.config.entityid))
526527
parsed_entity_id = urlparse(self.idp.config.entityid)
527528
url_map.append(("^{0}".format(parsed_entity_id.path[1:]),
528529
self._metadata_endpoint))
@@ -787,6 +788,10 @@ class SAMLVirtualCoFrontend(SAMLFrontend):
787788
KEY_ORGANIZATION = 'organization'
788789
KEY_ORGANIZATION_KEYS = ['display_name', 'name', 'url']
789790

791+
def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name):
792+
self.has_multiple_backends = False
793+
super().__init__(auth_req_callback_func, internal_attributes, config, base_url, name)
794+
790795
def handle_authn_request(self, context, binding_in):
791796
"""
792797
See super class
@@ -959,30 +964,42 @@ def _add_endpoints_to_config(self, config, co_name, backend_name):
959964

960965
return config
961966

962-
def _add_entity_id(self, config, co_name):
967+
def _add_entity_id(self, config, co_name, backend_name):
963968
"""
964969
Use the CO name to construct the entity ID for the virtual IdP
965970
for the CO and add it to the config. Also add it to the
966971
context.
967972
968973
The entity ID has the form
969974
970-
{base_entity_id}/{co_name}
975+
{base_entity_id}/{backend_name}/{co_name}
971976
972977
:type context: The current context
973978
:type config: satosa.satosa_config.SATOSAConfig
974979
:type co_name: str
980+
:type backend_name: str
975981
:rtype: satosa.satosa_config.SATOSAConfig
976982
977983
:param context:
978984
:param config: satosa proxy config
979985
:param co_name: CO name
986+
:param backend_name: Backend name
980987
981988
:return: config with updated entity ID
982989
"""
983990
base_entity_id = config['entityid']
984-
co_entity_id = "{}/{}".format(base_entity_id, quote_plus(co_name))
985-
config['entityid'] = co_entity_id
991+
# If not using template for entityId and does not has multiple backends, then for backward compatibility append co_name at end
992+
if "<co_name>" not in base_entity_id and not self.has_multiple_backends:
993+
base_entity_id = "{}/{}".format(base_entity_id, "<co_name>")
994+
995+
replace = [
996+
("<backend_name>", quote_plus(backend_name)),
997+
("<co_name>", quote_plus(co_name))
998+
]
999+
for _replace in replace:
1000+
base_entity_id = base_entity_id.replace(_replace[0], _replace[1])
1001+
1002+
config['entityid'] = base_entity_id
9861003

9871004
return config
9881005

@@ -1035,7 +1052,7 @@ def _co_names_from_config(self):
10351052

10361053
return co_names
10371054

1038-
def _create_co_virtual_idp(self, context):
1055+
def _create_co_virtual_idp(self, context, co_name=None):
10391056
"""
10401057
Create a virtual IdP to represent the CO.
10411058
@@ -1045,7 +1062,7 @@ def _create_co_virtual_idp(self, context):
10451062
:param context:
10461063
:return: An idp server
10471064
"""
1048-
co_name = self._get_co_name(context)
1065+
co_name = co_name or self._get_co_name(context)
10491066
context.decorate(self.KEY_CO_NAME, co_name)
10501067

10511068
# Verify that we are configured for this CO. If the CO was not
@@ -1068,7 +1085,7 @@ def _create_co_virtual_idp(self, context):
10681085
idp_config = self._add_endpoints_to_config(
10691086
idp_config, co_name, backend_name
10701087
)
1071-
idp_config = self._add_entity_id(idp_config, co_name)
1088+
idp_config = self._add_entity_id(idp_config, co_name, backend_name)
10721089
context.decorate(self.KEY_CO_ENTITY_ID, idp_config['entityid'])
10731090

10741091
# Use the overwritten IdP config to generate a pysaml2 config object
@@ -1100,10 +1117,22 @@ def _register_endpoints(self, backend_names):
11001117
:param backend_names: A list of backend names
11011118
:return: A list of url and endpoint function pairs
11021119
"""
1120+
1121+
# Throw exception if there is possibility of duplicate entity ids when using co_names with multiple backends
1122+
self.has_multiple_backends = len(backend_names) > 1
1123+
co_names = self._co_names_from_config()
1124+
all_entity_ids = []
1125+
for backend_name in backend_names:
1126+
for co_name in co_names:
1127+
all_entity_ids.append(self._add_entity_id(copy.deepcopy(self.idp_config), co_name, backend_name)['entityid'])
1128+
1129+
if len(all_entity_ids) != len(set(all_entity_ids)):
1130+
raise ValueError("Duplicate entities ids would be created for co-frontends, please make sure to make entity ids unique. "
1131+
"You can use <backend_name> and <co_name> to achieve it. See example yaml file.")
1132+
11031133
# Create a regex pattern that will match any of the CO names. We
11041134
# escape special characters like '+' and '.' that are valid
11051135
# characters in an URL encoded string.
1106-
co_names = self._co_names_from_config()
11071136
url_encoded_co_names = [re.escape(quote_plus(name)) for name in
11081137
co_names]
11091138
co_name_pattern = "|".join(url_encoded_co_names)
@@ -1155,4 +1184,29 @@ def _register_endpoints(self, backend_names):
11551184
logline = "Adding mapping {}".format(mapping)
11561185
logger.debug(logline)
11571186

1187+
if self.expose_entityid_endpoint():
1188+
for backend_name in backend_names:
1189+
for co_name in co_names:
1190+
idp_config = self._add_entity_id(copy.deepcopy(self.idp_config), co_name, backend_name)
1191+
entity_id = idp_config['entityid']
1192+
logger.debug("Exposing frontend entity endpoint = {}".format(entity_id))
1193+
parsed_entity_id = urlparse(entity_id)
1194+
metadata_endpoint = "^{0}".format(parsed_entity_id.path[1:])
1195+
the_callable = functools.partial(self._metadata_endpoint, co_name=co_name)
1196+
url_to_callable_mappings.append((metadata_endpoint, the_callable))
1197+
11581198
return url_to_callable_mappings
1199+
1200+
def _metadata_endpoint(self, context, co_name):
1201+
"""
1202+
Endpoint for retrieving the virtual frontend metadata
1203+
:type context: satosa.context.Context
1204+
:rtype: satosa.response.Response
1205+
1206+
:param context: The current context
1207+
:return: response with metadata
1208+
"""
1209+
# Using the context of the current request and saved state from the
1210+
# authentication request dynamically create an IdP instance.
1211+
self.idp = self._create_co_virtual_idp(context, co_name=co_name)
1212+
return super()._metadata_endpoint(context=context);

src/satosa/metadata_creation/saml_metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def _create_frontend_metadata(frontend_modules, backend_modules):
8080
logger.info(logline)
8181
idp_config = copy.deepcopy(frontend.config["idp_config"])
8282
idp_config = frontend._add_endpoints_to_config(idp_config, co_name, backend.name)
83-
idp_config = frontend._add_entity_id(idp_config, co_name)
83+
idp_config = frontend._add_entity_id(idp_config, co_name, backend.name)
8484
idp_config = frontend._overlay_for_saml_metadata(idp_config, co_name)
8585
entity_desc = _create_entity_descriptor(idp_config)
8686
frontend_metadata[frontend.name].append(entity_desc)

tests/satosa/frontends/test_saml2.py

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Tests for the SAML frontend module src/frontends/saml2.py.
33
"""
4+
import copy
45
import itertools
56
import re
67
from collections import Counter
@@ -28,7 +29,6 @@
2829
from satosa.internal import AuthenticationInformation
2930
from satosa.internal import InternalData
3031
from satosa.state import State
31-
from satosa.context import Context
3232
from tests.users import USERS
3333
from tests.util import FakeSP, create_metadata_from_config_dict
3434

@@ -431,6 +431,7 @@ def test_load_idp_dynamic_entity_id(self, idp_conf):
431431

432432
class TestSAMLVirtualCoFrontend(TestSAMLFrontend):
433433
BACKEND = "test_backend"
434+
BACKEND_1 = "test_backend_1"
434435
CO = "MESS"
435436
CO_O = "organization"
436437
CO_C = "countryname"
@@ -441,8 +442,8 @@ class TestSAMLVirtualCoFrontend(TestSAMLFrontend):
441442
CO_O: ["Medium Energy Synchrotron Source"],
442443
CO_C: ["US"],
443444
CO_CO: ["United States"],
444-
CO_NOREDUORGACRONYM: ["MESS"]
445-
}
445+
CO_NOREDUORGACRONYM: ["MESS"],
446+
}
446447
KEY_SSO = "single_sign_on_service"
447448

448449
@pytest.fixture
@@ -471,10 +472,10 @@ def frontend(self, idp_conf, sp_conf):
471472
# endpoints, and the collaborative organization configuration to
472473
# create the configuration for the frontend.
473474
conf = {
474-
"idp_config": idp_conf,
475-
"endpoints": ENDPOINTS,
476-
"collaborative_organizations": [collab_org]
477-
}
475+
"idp_config": idp_conf,
476+
"endpoints": ENDPOINTS,
477+
"collaborative_organizations": [collab_org],
478+
}
478479

479480
# Use a richer set of internal attributes than what is provided
480481
# for the parent class so that we can test for the static SAML
@@ -504,10 +505,13 @@ def context(self, context):
504505
that would be available during a SAML flow and that would include
505506
a path and target_backend that indicates the CO.
506507
"""
507-
context.path = "{}/{}/sso/redirect".format(self.BACKEND, self.CO)
508-
context.target_backend = self.BACKEND
508+
return self._make_context(context, self.BACKEND, self.CO)
509509

510-
return context
510+
def _make_context(self, context, backend, co_name):
511+
_context = copy.deepcopy(context)
512+
_context.path = "{}/{}/sso/redirect".format(backend, co_name)
513+
_context.target_backend = backend
514+
return _context
511515

512516
def test_create_state_data(self, frontend, context, idp_conf):
513517
frontend._create_co_virtual_idp(context)
@@ -542,6 +546,17 @@ def test_create_co_virtual_idp(self, frontend, context, idp_conf):
542546
assert idp_server.config.entityid == expected_entityid
543547
assert all(sso in sso_endpoints for sso in expected_endpoints)
544548

549+
def test_create_co_virtual_idp_with_entity_id_templates(self, frontend, context):
550+
frontend.idp_config['entityid'] = "{}/Saml2IDP/proxy.xml".format(BASE_URL)
551+
expected_entity_id = "{}/Saml2IDP/proxy.xml/{}".format(BASE_URL, self.CO)
552+
idp_server = frontend._create_co_virtual_idp(context)
553+
assert idp_server.config.entityid == expected_entity_id
554+
555+
frontend.idp_config['entityid'] = "{}/<backend_name>/idp/<co_name>".format(BASE_URL)
556+
expected_entity_id = "{}/{}/idp/{}".format(BASE_URL, context.target_backend, self.CO)
557+
idp_server = frontend._create_co_virtual_idp(context)
558+
assert idp_server.config.entityid == expected_entity_id
559+
545560
def test_register_endpoints(self, frontend, context):
546561
idp_server = frontend._create_co_virtual_idp(context)
547562
url_map = frontend.register_endpoints([self.BACKEND])
@@ -553,6 +568,28 @@ def test_register_endpoints(self, frontend, context):
553568
for endpoint in all_idp_endpoints:
554569
assert any(pat.match(endpoint) for pat in compiled_regex)
555570

571+
def test_register_endpoints_throws_error_in_case_duplicate_entity_ids(self, frontend):
572+
with pytest.raises(ValueError):
573+
frontend.register_endpoints([self.BACKEND, self.BACKEND_1])
574+
575+
def test_register_endpoints_with_metadata_endpoints(self, frontend, context):
576+
frontend.idp_config['entityid'] = "{}/<backend_name>/idp/<co_name>".format(BASE_URL)
577+
frontend.config['entityid_endpoint'] = True
578+
idp_server_1 = frontend._create_co_virtual_idp(context)
579+
context_2 = self._make_context(context, self.BACKEND_1, self.CO)
580+
idp_server_2 = frontend._create_co_virtual_idp(context_2)
581+
582+
url_map = frontend.register_endpoints([self.BACKEND, self.BACKEND_1])
583+
expected_idp_endpoints = [urlparse(endpoint[0]).path[1:] for server in [idp_server_1, idp_server_2]
584+
for endpoint in server.config._idp_endpoints[self.KEY_SSO]]
585+
for server in [idp_server_1, idp_server_2]:
586+
expected_idp_endpoints.append(urlparse(server.config.entityid).path[1:])
587+
588+
compiled_regex = [re.compile(regex) for regex, _ in url_map]
589+
590+
for endpoint in expected_idp_endpoints:
591+
assert any(pat.match(endpoint) for pat in compiled_regex)
592+
556593
def test_co_static_attributes(self, frontend, context, internal_response,
557594
idp_conf, sp_conf):
558595
# Use the frontend and context fixtures to dynamically create the
@@ -563,9 +600,8 @@ def test_co_static_attributes(self, frontend, context, internal_response,
563600
# and then use those to dynamically update the ipd_conf fixture.
564601
co_name = frontend._get_co_name(context)
565602
backend_name = context.target_backend
566-
idp_conf = frontend._add_endpoints_to_config(idp_conf, co_name,
567-
backend_name)
568-
idp_conf = frontend._add_entity_id(idp_conf, co_name)
603+
idp_conf = frontend._add_endpoints_to_config(idp_conf, co_name, backend_name)
604+
idp_conf = frontend._add_entity_id(idp_conf, co_name, backend_name)
569605

570606
# Use a utility function to serialize the idp_conf IdP configuration
571607
# fixture to a string and then dynamically update the sp_conf
@@ -597,9 +633,9 @@ def test_co_static_attributes(self, frontend, context, internal_response,
597633
"name_id_policy": NameIDPolicy(format=NAMEID_FORMAT_TRANSIENT),
598634
"in_response_to": None,
599635
"destination": sp_config.endpoint(
600-
"assertion_consumer_service",
601-
binding=BINDING_HTTP_REDIRECT
602-
)[0],
636+
"assertion_consumer_service",
637+
binding=BINDING_HTTP_REDIRECT
638+
)[0],
603639
"sp_entity_id": sp_conf["entityid"],
604640
"binding": BINDING_HTTP_REDIRECT
605641
}
@@ -646,12 +682,10 @@ def test_should_map_unspecified(self):
646682

647683
def test_should_map_public(self):
648684
assert (
649-
subject_type_to_saml_nameid_format("public")
650-
== NAMEID_FORMAT_PERSISTENT
685+
subject_type_to_saml_nameid_format("public") == NAMEID_FORMAT_PERSISTENT
651686
)
652687

653688
def test_should_map_pairwise(self):
654689
assert (
655-
subject_type_to_saml_nameid_format("pairwise")
656-
== NAMEID_FORMAT_TRANSIENT
690+
subject_type_to_saml_nameid_format("pairwise") == NAMEID_FORMAT_TRANSIENT
657691
)

0 commit comments

Comments
 (0)