Skip to content

Full symmetric meshed metadata proxy #171

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/satosa/backends/openid_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def start_auth(self, context, request_info):
args.update(self.config["client"]["auth_req_params"])
auth_req = self.client.construct_AuthorizationRequest(request_args=args)
login_url = auth_req.request(self.client.authorization_endpoint)
context.state['target_backend'] = self.name
return Redirect(login_url)

def register_endpoints(self):
Expand Down
32 changes: 32 additions & 0 deletions src/satosa/backends/saml2.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def authn_request(self, context, entity_id):
self.outstanding_queries[req_id] = req

context.state[self.name] = {"relay_state": relay_state}
context.state['target_backend'] = self.name
return make_saml_response(binding, ht_args)

def authn_response(self, context, binding):
Expand Down Expand Up @@ -445,3 +446,34 @@ def to_dict(self):
_dict['name_id'] = None

return _dict

class SAMLMirrorBackend(SAMLBackend):
"""
A saml2 backend module (acting as a SP) that gives out requesting SP dependant entityID in authnRequest.
"""
def start_auth(self, context, internal_req):
"""
See super class method satosa.backends.base.BackendModule#start_auth
:type context: satosa.context.Context
:type internal_req: satosa.internal_data.InternalRequest
:rtype: satosa.response.Response
"""

satosa_logging(logger, logging.DEBUG, "Target Frontend: %s" % context.target_frontend, context.state)
satosa_logging(logger, logging.DEBUG, "SP entityid: %s" % internal_req.requester, context.state)
satosa_logging(logger, logging.DEBUG, "Internal SP: %s" % self.sp.config.entityid, context.state)

requester_desc = self._urlenc(internal_req.requester)
self.sp.config.entityid = self.config["sp_config"]["entityid"] + "/" + context.target_frontend + "/" + requester_desc

satosa_logging(logger, logging.DEBUG, "New Internal SP: %s" % self.sp.config.entityid, context.state)

return super().start_auth(context, internal_req)

def _urlenc(self, s):
"""
Create short unique url-safe hash of string
"""

desc = urlsafe_b64encode(s.encode('utf-8')).decode('utf-8')
return desc
64 changes: 63 additions & 1 deletion src/satosa/frontends/saml2.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import logging
from urllib.parse import urlparse
from base64 import urlsafe_b64encode, urlsafe_b64decode

from saml2 import SAMLError, xmldsig
from saml2.config import IdPConfig
Expand All @@ -25,6 +26,8 @@
from ..saml_util import make_saml_response
import satosa.util as util

from ..metadata_creation.description import (MetadataDescription, OrganizationDesc,
ContactPersonDesc, UIInfoDesc)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -461,6 +464,65 @@ def _get_sp_display_name(self, idp, entity_id):

return None

def get_metadata_desc(self):
"""
See super class satosa.frontends.frontend_base.FrontendModule#get_metadata_desc
:rtype: satosa.metadata_creation.description.MetadataDescription
"""
entity_descriptions = []

sp_entities = self.idp.metadata.with_descriptor("spsso")
for entity_id, entity in sp_entities.items():
description = MetadataDescription(urlsafe_b64encode(entity_id.encode("utf-8")).decode("utf-8"))

# Add organization info
try:
organization_info = entity["organization"]
except KeyError:
pass
else:
organization = OrganizationDesc()
for name_info in organization_info.get("organization_name", []):
organization.add_name(name_info["text"], name_info["lang"])
for display_name_info in organization_info.get("organization_display_name", []):
organization.add_display_name(display_name_info["text"], display_name_info["lang"])
for url_info in organization_info.get("organization_url", []):
organization.add_url(url_info["text"], url_info["lang"])
description.organization = organization

# Add contact person info
try:
contact_persons = entity["contact_person"]
except KeyError:
pass
else:
for person in contact_persons:
person_desc = ContactPersonDesc()
person_desc.contact_type = person.get("contact_type")
for address in person.get('email_address', []):
person_desc.add_email_address(address["text"])
if "given_name" in person:
person_desc.given_name = person["given_name"]["text"]
if "sur_name" in person:
person_desc.sur_name = person["sur_name"]["text"]

description.add_contact_person(person_desc)

# Add UI info
ui_info = self.idp.metadata.extension(entity_id, "spsso_descriptor", "{}&UIInfo".format(UI_NAMESPACE))
if ui_info:
ui_info = ui_info[0]
ui_info_desc = UIInfoDesc()
for desc in ui_info.get("description", []):
ui_info_desc.add_description(desc["text"], desc["lang"])
for name in ui_info.get("display_name", []):
ui_info_desc.add_display_name(name["text"], name["lang"])
for logo in ui_info.get("logo", []):
ui_info_desc.add_logo(logo["text"], logo["width"], logo["height"], logo.get("lang"))
description.ui_info = ui_info_desc

entity_descriptions.append(description)
return entity_descriptions

class SAMLMirrorFrontend(SAMLFrontend):
"""
Expand Down Expand Up @@ -521,7 +583,7 @@ def _load_idp_dynamic_entity_id(self, state):
"""
# Change the idp entity id dynamically
idp_config_file = copy.deepcopy(self.idp_config)
idp_config_file["entityid"] = "{}/{}".format(self.idp_config["entityid"], state[self.name]["target_entity_id"])
idp_config_file["entityid"] = "{}/{}/{}".format(self.idp_config["entityid"], state["target_backend"], state[self.name]["target_entity_id"])
idp_config = IdPConfig().load(idp_config_file, metadata_construction=False)
return Server(config=idp_config)

Expand Down
2 changes: 1 addition & 1 deletion src/satosa/metadata_creation/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def to_dict(self):
ui_info["display_name"] = self._display_name
if self._logos:
ui_info["logo"] = self._logos
return {"service": {"idp": {"ui_info": ui_info}}} if ui_info else {}
return {"service": {"ui_info": ui_info}} if ui_info else {}


class OrganizationDesc(object):
Expand Down
103 changes: 83 additions & 20 deletions src/satosa/metadata_creation/saml_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,49 @@
from saml2.validate import valid_instance

from ..backends.saml2 import SAMLBackend
from ..backends.saml2 import SAMLMirrorBackend
from ..backends.openid_connect import OpenIDConnectBackend
from ..frontends.saml2 import SAMLFrontend
from ..frontends.saml2 import SAMLMirrorFrontend
from ..frontends.openid_connect import OpenIDConnectFrontend
from ..plugin_loader import load_frontends, load_backends

from ..metadata_creation.description import MetadataDescription
from base64 import urlsafe_b64encode, urlsafe_b64decode

logger = logging.getLogger(__name__)

def urlenc(s):
enc = urlsafe_b64encode(s.encode('utf-8')).decode('utf-8')
return enc

def _create_entity_descriptor(entity_config):
cnf = Config().load(copy.deepcopy(entity_config), metadata_construction=True)
return entity_descriptor(cnf)

def _create_mirrored_sp_entity_config(backend_instance, target_metadata_info, frontend_name):
def _merge_dicts(a, b):
for key, value in b.items():
#if key in ["organization", "contact_person"]:
# avoid copying contact info from the target provider
#continue
if key in a and isinstance(value, dict):
a[key] = _merge_dicts(a[key], b[key])
else:
a[key] = value

def _create_backend_metadata(backend_modules):
backend_metadata = {}

for plugin_module in backend_modules:
if isinstance(plugin_module, SAMLBackend):
logger.info("Generating SAML backend '%s' metadata", plugin_module.name)
backend_metadata[plugin_module.name] = [_create_entity_descriptor(plugin_module.config["sp_config"])]

return backend_metadata
return a

if "service" in target_metadata_info:
if not "sp" in target_metadata_info["service"]:
target_metadata_info["service"]["sp"] = dict()
target_metadata_info["service"]["sp"]["ui_info"] = target_metadata_info["service"].pop("ui_info")
merged_conf = _merge_dicts(copy.deepcopy(backend_instance.config["sp_config"]), target_metadata_info)
proxy_entity_id = backend_instance.config["sp_config"]["entityid"]
merged_conf["entityid"] = "{}/{}/{}".format(proxy_entity_id, frontend_name, target_metadata_info["entityid"])
return merged_conf

def _create_mirrored_entity_config(frontend_instance, target_metadata_info, backend_name):
def _create_mirrored_idp_entity_config(frontend_instance, target_metadata_info, backend_name):
def _merge_dicts(a, b):
for key, value in b.items():
if key in ["organization", "contact_person"]:
Expand All @@ -44,30 +63,74 @@ def _merge_dicts(a, b):

return a

if "service" in target_metadata_info:
if not "idp" in target_metadata_info["service"]:
target_metadata_info["service"]["idp"] = dict()
target_metadata_info["service"]["idp"]["ui_info"] = target_metadata_info["service"].pop("ui_info")
merged_conf = _merge_dicts(copy.deepcopy(frontend_instance.config["idp_config"]), target_metadata_info)
full_config = frontend_instance._load_endpoints_to_config(backend_name, target_metadata_info["entityid"],
config=merged_conf)

proxy_entity_id = frontend_instance.config["idp_config"]["entityid"]
full_config["entityid"] = "{}/{}".format(proxy_entity_id, target_metadata_info["entityid"])
full_config["entityid"] = "{}/{}/{}".format(proxy_entity_id, backend_name, target_metadata_info["entityid"])
return full_config

def _create_backend_metadata(backend_modules, frontend_modules):
backend_metadata = defaultdict(list)

for backend in backend_modules:
if isinstance(backend, SAMLMirrorBackend):
backend_entityid = backend.config["sp_config"]["entityid"]
frontend_metadata = defaultdict(list)
for frontend in frontend_modules:
if isinstance(frontend, SAMLFrontend):
logger.info("Creating SAML backend Mirror metadata for '{}' and frontend '{}'".format(backend.name, frontend.name))
frontend.register_endpoints([backend.name])
meta_desc = frontend.get_metadata_desc()
for desc in meta_desc:
logger.info("Backend %s EntityID %s" % (backend.name, urlsafe_b64decode(desc.to_dict()["entityid"]).decode("utf-8")))
mirrored_sp_entity_config = _create_mirrored_sp_entity_config(backend, desc.to_dict(), frontend.name)
entity_desc = _create_entity_descriptor(mirrored_sp_entity_config)
backend_metadata[backend.name].append(entity_desc)
elif isinstance(frontend, OpenIDConnectFrontend):
logger.info("Creating SAML backend Mirror metadata for '{}' and OIDC frontend '{}'".format(backend.name, frontend.name))
frontend.register_endpoints([backend.name])
for client_id, client in frontend.provider.clients.items():
logger.info("OIDC client_id %s %s" % (client_id, client.get("client_name")))
backend.config["sp_config"]["entityid"] = backend_entityid + "/" + frontend.name + "/" + urlenc(client_id)
backend_metadata[backend.name].append(_create_entity_descriptor(backend.config["sp_config"]))
elif isinstance(backend, SAMLBackend):
logger.info("Creating SAML backend '%s' metadata", backend.name)
logger.info("Backend %s EntityID %s" % (backend.name, backend.config["sp_config"]["entityid"]))
backend_metadata[backend.name].append(_create_entity_descriptor(backend.config["sp_config"]))

return backend_metadata


def _create_frontend_metadata(frontend_modules, backend_modules):
frontend_metadata = defaultdict(list)

for frontend in frontend_modules:
if isinstance(frontend, SAMLMirrorFrontend):
for backend in backend_modules:
logger.info("Creating metadata for frontend '%s' and backend '%s'".format(frontend.name, backend.name))
meta_desc = backend.get_metadata_desc()
for desc in meta_desc:
entity_desc = _create_entity_descriptor(
_create_mirrored_entity_config(frontend, desc.to_dict(), backend.name))
frontend_metadata[frontend.name].append(entity_desc)
if isinstance(backend, SAMLBackend):
logger.info("Creating SAML Mirrored metadata for frontend '{}' and backend '{}'".format(frontend.name, backend.name))
meta_desc = backend.get_metadata_desc()
for desc in meta_desc:
mirrored_idp_entity_config = _create_mirrored_idp_entity_config(frontend, desc.to_dict(), backend.name)
entity_desc = _create_entity_descriptor(mirrored_idp_entity_config)
frontend_metadata[frontend.name].append(entity_desc)
if isinstance(backend, OpenIDConnectBackend):
logger.info("Creating SAML Mirrored metadata for frontend '{}' and OIDC backend '{}'".format(frontend.name, backend.name))
meta_desc = backend.get_metadata_desc()
for desc in meta_desc:
mirrored_idp_entity_config = _create_mirrored_idp_entity_config(frontend, desc.to_dict(), backend.name)
entity_desc = _create_entity_descriptor(mirrored_idp_entity_config)
frontend_metadata[frontend.name].append(entity_desc)
elif isinstance(frontend, SAMLFrontend):
logger.info("Creating SAML frontend '%s' metadata" % frontend.name)
frontend.register_endpoints([backend.name for backend in backend_modules])
entity_desc = _create_entity_descriptor(frontend.idp_config)
entity_desc = _create_entity_descriptor(frontend.config["idp_config"])
frontend_metadata[frontend.name].append(entity_desc)

return frontend_metadata
Expand All @@ -88,7 +151,7 @@ def create_entity_descriptors(satosa_config):
logger.info("Loaded frontend plugins: {}".format([frontend.name for frontend in frontend_modules]))
logger.info("Loaded backend plugins: {}".format([backend.name for backend in backend_modules]))

backend_metadata = _create_backend_metadata(backend_modules)
backend_metadata = _create_backend_metadata(backend_modules, frontend_modules)
frontend_metadata = _create_frontend_metadata(frontend_modules, backend_modules)

return frontend_metadata, backend_metadata
Expand Down Expand Up @@ -132,4 +195,4 @@ def create_signed_entity_descriptor(entity_descriptor, security_context, valid_f
if not valid_instance(entity_desc):
raise ValueError("Could not construct valid EntityDescriptor tag")

return xmldoc
return xmldoc
18 changes: 15 additions & 3 deletions src/satosa/scripts/satosa_saml_metadata.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import logging
import sys

import click
from saml2.config import Config
Expand All @@ -9,6 +11,7 @@
from ..metadata_creation.saml_metadata import create_signed_entity_descriptor
from ..satosa_config import SATOSAConfig

logger = logging.getLogger(__name__)

def _get_security_context(key, cert):
conf = Config()
Expand All @@ -28,9 +31,10 @@ def _create_split_entity_descriptors(entities, secc, valid):

def _create_merged_entities_descriptors(entities, secc, valid, name):
output = []
frontend_entity_descriptors = [e for sublist in entities.values() for e in sublist]
for frontend in frontend_entity_descriptors:
output.append((create_signed_entity_descriptor(frontend, secc, valid), name))
entity_descriptors = [e for sublist in entities.values() for e in sublist]
for entity in entity_descriptors:
print("entityID: {}".format(entity.entity_id))
output.append((create_signed_entities_descriptor(entity_descriptors, secc, valid), name))

return output

Expand All @@ -40,6 +44,14 @@ def create_and_write_saml_metadata(proxy_conf, key, cert, dir, valid, split_fron
"""
Generates SAML metadata for the given PROXY_CONF, signed with the given KEY and associated CERT.
"""

stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.INFO)

root_logger = logging.getLogger("")
root_logger.addHandler(stderr_handler)
root_logger.setLevel(logging.INFO)

satosa_config = SATOSAConfig(proxy_conf)
secc = _get_security_context(key, cert)
frontend_entities, backend_entities = create_entity_descriptors(satosa_config)
Expand Down
20 changes: 19 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,30 @@ def sp_conf(cert_and_key):
},
"want_response_signed": False,
"allow_unsolicited": True,
"name_id_format": [NAMEID_FORMAT_PERSISTENT]
"name_id_format": [NAMEID_FORMAT_PERSISTENT],
"ui_info": {
"display_name": [{"text": "SATOSA Test SP", "lang": "en"}],
"description": [{"text": "Test SP for SATOSA unit tests.", "lang": "en"}],
"logo": [{"text": "https://sp.example.com/static/logo.png", "width": "120", "height": "60",
"lang": "en"}],
},
},
},
"cert_file": cert_and_key[0],
"key_file": cert_and_key[1],
"metadata": {"inline": []},
"organization": {
"name": [["Test SP Org.", "en"]],
"display_name": [["Test SP", "en"]],
"url": [["https://sp.example.com/about", "en"]]
},
"contact_person": [
{"given_name": "Test SP", "sur_name": "Support", "email_address": ["[email protected]"],
"contact_type": "support"
},
{"given_name": "Test SP", "sur_name": "Tech support",
"email_address": ["[email protected]"], "contact_type": "technical"}
]
}

return spconfig
Expand Down
Loading