diff --git a/src/satosa/backends/base.py b/src/satosa/backends/base.py index 8d0432da8..9ea9142a6 100644 --- a/src/satosa/backends/base.py +++ b/src/satosa/backends/base.py @@ -10,13 +10,14 @@ class BackendModule(object): Base class for a backend module. """ - def __init__(self, auth_callback_func, internal_attributes, base_url, name): + def __init__(self, auth_callback_func, internal_attributes, base_url, name, logout_callback_func=None): """ :type auth_callback_func: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[string, dict[str, str | list[str]]] :type base_url: str :type name: str + :type logout_callback_func: :param auth_callback_func: Callback should be called by the module after the authorization in the backend is done. @@ -25,8 +26,11 @@ def __init__(self, auth_callback_func, internal_attributes, base_url, name): RP's expects namevice. :param base_url: base url of the service :param name: name of the plugin + :param logout_callback_func: Callback should be called by the module after + the logout in the backend is complete """ self.auth_callback_func = auth_callback_func + self.logout_callback_func = logout_callback_func self.internal_attributes = internal_attributes self.converter = AttributeMapper(internal_attributes) self.base_url = base_url @@ -46,6 +50,20 @@ def start_auth(self, context, internal_request): """ raise NotImplementedError() + def start_logout(self, context, internal_request): + """ + This is the start up function of the backend logout. + + :type context: satosa.context.Context + :type internal_request: satosa.internal.InternalData + :rtype + + :param context: the request context + :param internal_request: Information about the logout request + :return: + """ + raise NotImplementedError() + def register_endpoints(self): """ Register backend functions to endpoint urls. diff --git a/src/satosa/backends/reflector.py b/src/satosa/backends/reflector.py index 6a9055485..da03fa478 100644 --- a/src/satosa/backends/reflector.py +++ b/src/satosa/backends/reflector.py @@ -21,6 +21,7 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :type internal_attributes: dict[str, dict[str, list[str] | str]] :type config: dict[str, Any] :type base_url: str diff --git a/src/satosa/backends/saml2.py b/src/satosa/backends/saml2.py index ec99cad06..5fb18f52e 100644 --- a/src/satosa/backends/saml2.py +++ b/src/satosa/backends/saml2.py @@ -17,6 +17,7 @@ from saml2.authn_context import requested_authn_context from saml2.samlp import RequesterID from saml2.samlp import Scoping +from saml2.saml import NameID import satosa.logging_util as lu import satosa.util as util @@ -25,10 +26,12 @@ from satosa.base import STATE_KEY as STATE_KEY_BASE from satosa.context import Context from satosa.internal import AuthenticationInformation +from satosa.internal import LogoutInformation from satosa.internal import InternalData from satosa.exception import SATOSAAuthenticationError from satosa.exception import SATOSAMissingStateError from satosa.exception import SATOSAAuthenticationFlowError +from satosa.exception import SATOSAUnknownError from satosa.response import SeeOther, Response from satosa.saml_util import make_saml_response from satosa.metadata_creation.description import ( @@ -92,7 +95,7 @@ class SAMLBackend(BackendModule, SAMLBaseModule): VALUE_ACR_COMPARISON_DEFAULT = 'exact' - def __init__(self, outgoing, internal_attributes, config, base_url, name): + def __init__(self, outgoing, internal_attributes, config, base_url, name, logout): """ :type outgoing: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -100,6 +103,7 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :type config: dict[str, Any] :type base_url: str :type name: str + :type logout: :param outgoing: Callback should be called by the module after the authorization in the backend is done. @@ -107,8 +111,9 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name): :param config: The module config :param base_url: base url of the service :param name: name of the plugin + :param logout: Logout callback """ - super().__init__(outgoing, internal_attributes, base_url, name) + super().__init__(outgoing, internal_attributes, base_url, name, logout) self.config = self.init_config(config) self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV) @@ -196,6 +201,26 @@ def start_auth(self, context, internal_req): return self.authn_request(context, entity_id) + def start_logout(self, context, internal_req, internal_authn_resp): + """ + See super class method satosa.backends.base.BackendModule#start_logout + + :type context: satosa.context.Context + :type internal_req: satosa.internal.InternalData + :rtype: satosa.response.Response + """ + + if internal_authn_resp is None: + message = "Session Information Deleted" + status = "500 FAILED" + return Response(message=message, status=status) + entity_id = internal_authn_resp["auth_info"]["issuer"] + if entity_id is None: + message = "Logout Failed" + status = "500 FAILED" + return Response(message=message, status=status) + return self.logout_request(context, entity_id, internal_authn_resp) + def disco_query(self, context): """ Makes a request to the discovery server @@ -471,6 +496,83 @@ def authn_response(self, context, binding): context.state.pop(Context.KEY_FORCE_AUTHN, None) return self.auth_callback_func(context, self._translate_response(authn_response, context.state)) + def logout_request(self, context, entity_id, internal_authn_resp): + """ + Perform Logout request on idp with given entity_id. + This is the start of single logout. + + :type context: satosa.context.Context + :type entity_id: str + :rtype: satosa.response.Response + + :param context: The current context + :param entity_id: Target IDP entity id + :return: response to the user agent + """ + try: + binding, destination = self.sp.pick_binding( + "single_logout_service", None, "idpsso", entity_id=entity_id + ) + msg = "binding: {}, destination: {}".format(binding, destination) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + + slo_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["single_logout_service"][0] + name_id_format = self.sp.config.getattr("name_id_format", "sp") + name_id = internal_authn_resp["subject_id"] + name_id = NameID(format=name_id_format, text=name_id) + session_indexes = internal_authn_resp["auth_info"]["session_index"] + sign = self.sp.config.getattr("logout_requests_signed", "sp") + req_id, req = self.sp.create_logout_request( + destination, issuer_entity_id=entity_id, name_id=name_id, + session_indexes=session_indexes, sign=sign + ) + msg = "req_id: {}, req: {}".format(req_id, req) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + relay_state = util.rndstr() + ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state) + msg = "ht_args: {}".format(ht_args) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + except Exception as exc: + msg = "Failed to construct the LogoutRequest for state" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline, exc_info=True) + status = "500 FAILED" + return Response(message=msg, status=status) + return make_saml_response(binding, ht_args) + + def logout_response(self, context, binding): + """ + Endpoint for the idp logout response + + :type context: satosa.context.Context + :type binding: str + :rtype: satosa.response.Response + + :param context: The current context + :param binding: SAML binding type + :return Response + """ + if not context.request.get("SAMLResponse"): + msg = "Missing Response for state" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + raise SATOSAUnknownError(context.state, "Missing Response") + + try: + logout_response = self.sp.parse_logout_request_response( + context.request["SAMLResponse"], binding) + except Exception as err: + msg = "Failed to parse logout response for state" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline, exc_info=True) + message = "Logout Failed" + status = "500 FAILED" + return Response(message=message, status=status) + return self.logout_callback_func(context) + def disco_response(self, context): """ Endpoint for the discovery server response @@ -523,11 +625,14 @@ def _translate_response(self, response, state): if authenticating_authorities else None ) + session_indexes = [] + session_indexes.append(response.session_info()['session_index']) auth_info = AuthenticationInformation( auth_class_ref=authn_context_ref, timestamp=authn_instant, authority=authenticating_authority, issuer=issuer, + session_index=session_indexes, ) # The SAML response may not include a NameID. @@ -562,6 +667,25 @@ def _translate_response(self, response, state): return internal_resp + def _translate_logout_response(self, response, state): + timestamp = response.response.issue_instant + issuer = response.response.issuer.text + + status = { + "status_code": response.response.status.status_code.value, + } + + logout_info = LogoutInformation( + timestamp=timestamp, + issuer=issuer, + status=status + ) + + msg = "logout response content" + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.debug(logline) + return logout_info + def _metadata_endpoint(self, context): """ Endpoint for retrieving the backend metadata @@ -621,6 +745,11 @@ def register_endpoints(self): url_map.append( ("^%s/%s$" % (self.name, "reload-metadata"), self._reload_metadata)) + for endp, binding in sp_endpoints["single_logout_service"]: + parsed_endp = urlparse(endp) + url_map.append(("^%s$" % parsed_endp.path[1:], + functools.partial(self.logout_response, binding=binding))) + return url_map def _reload_metadata(self, context): diff --git a/src/satosa/base.py b/src/satosa/base.py index 1e17c8cbe..001694fc5 100644 --- a/src/satosa/base.py +++ b/src/satosa/base.py @@ -12,6 +12,12 @@ from satosa.response import NotFound from satosa.response import Redirect from .context import Context +from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError +from .plugin_loader import load_backends, load_frontends +from .plugin_loader import load_request_microservices, load_response_microservices +from .plugin_loader import load_database +from .routing import ModuleRouter, SATOSANoBoundEndpointError +from .state import cookie_to_state, SATOSAStateError, State, state_to_cookie from .exception import SATOSAAuthenticationError from .exception import SATOSAAuthenticationFlowError from .exception import SATOSABadRequestError @@ -54,10 +60,14 @@ def __init__(self, config): logger.info("Loading backend modules...") backends = load_backends(self.config, self._auth_resp_callback_func, - self.config["INTERNAL_ATTRIBUTES"]) + self.config["INTERNAL_ATTRIBUTES"], + self._logout_resp_callback_func + ) logger.info("Loading frontend modules...") frontends = load_frontends(self.config, self._auth_req_callback_func, - self.config["INTERNAL_ATTRIBUTES"]) + self.config["INTERNAL_ATTRIBUTES"], + self._logout_req_callback_func + ) self.response_micro_services = [] self.request_micro_services = [] @@ -77,6 +87,11 @@ def __init__(self, config): self.config["BASE"])) self._link_micro_services(self.response_micro_services, self._auth_resp_finish) + load_db = self.config.get("LOGOUT_ENABLED", False) + if load_db: + logger.info("Loading database...") + self.db = load_database(self.config) + self.module_router = ModuleRouter(frontends, backends, self.request_micro_services + self.response_micro_services) @@ -115,23 +130,63 @@ def _auth_req_callback_func(self, context, internal_request): return self._auth_req_finish(context, internal_request) + def _logout_req_callback_func(self, context, internal_request): + """ + This function is called by a frontend module when a logout request has been processed. + + :type context: satosa.context.Context + :typr internal_request: + :rtype: + + :param context: The request context + :param internal_request: request processed by the frontend + :return Response + """ + state = context.state + state[STATE_KEY] = {"requester": internal_request.requester} + msg = "Requesting provider: {}".format(internal_request.requester) + logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg) + logger.info(logline) + return self._logout_req_finish(context, internal_request) + def _auth_req_finish(self, context, internal_request): backend = self.module_router.backend_routing(context) context.request = None return backend.start_auth(context, internal_request) + def _logout_req_finish(self, context, internal_request): + backend = self.module_router.backend_routing(context) + context.request = None + if hasattr(self, "db"): + internal_authn_resp = self.db.get_authn_resp(context.state) + self.db.delete_session(context.state) + else: + internal_authn_resp = None + context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True) + return backend.start_logout(context, internal_request, internal_authn_resp) + def _auth_resp_finish(self, context, internal_response): user_id_to_attr = self.config["INTERNAL_ATTRIBUTES"].get("user_id_to_attr", None) if user_id_to_attr: internal_response.attributes[user_id_to_attr] = [internal_response.subject_id] # remove all session state unless CONTEXT_STATE_DELETE is False - context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True) context.request = None + if hasattr(self, "db"): + self.db.store_authn_resp(context.state, internal_response) + self.db.get_authn_resp(context.state) + else: + context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True) frontend = self.module_router.frontend_routing(context) return frontend.handle_authn_response(context, internal_response) + def _logout_resp_finish(self, context): + context.request = None + + frontend = self.module_router.frontend_routing(context) + return frontend.handle_logout_response(context) + def _auth_resp_callback_func(self, context, internal_response): """ This function is called by a backend module when the authorization is @@ -163,6 +218,21 @@ def _auth_resp_callback_func(self, context, internal_response): return self._auth_resp_finish(context, internal_response) + def _logout_resp_callback_func(self, context): + """ + This function is called by a backend module when logout is complete + + :type context: satosa.context.Context + :type internal_response: satosa.internal.LogoutInformation + :rtype: satosa.response.Response + + :param context: The request context + :param internal_response: The logout response + """ + context.request = None + context.state["ROUTER"] = "idp" + return self._logout_resp_finish(context) + def _handle_satosa_authentication_error(self, error): """ Sends a response to the requester about the error diff --git a/src/satosa/frontends/base.py b/src/satosa/frontends/base.py index 52840a85c..a191b6b6a 100644 --- a/src/satosa/frontends/base.py +++ b/src/satosa/frontends/base.py @@ -9,7 +9,7 @@ class FrontendModule(object): Base class for a frontend module. """ - def __init__(self, auth_req_callback_func, internal_attributes, base_url, name): + def __init__(self, auth_req_callback_func, internal_attributes, base_url, name, logout_req_callback_func=None): """ :type auth_req_callback_func: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response @@ -21,6 +21,7 @@ def __init__(self, auth_req_callback_func, internal_attributes, base_url, name): :param name: name of the plugin """ self.auth_req_callback_func = auth_req_callback_func + self.logout_req_callback_func = logout_req_callback_func self.internal_attributes = internal_attributes self.converter = AttributeMapper(internal_attributes) self.base_url = base_url diff --git a/src/satosa/frontends/openid_connect.py b/src/satosa/frontends/openid_connect.py index 88041b373..33dc302c5 100644 --- a/src/satosa/frontends/openid_connect.py +++ b/src/satosa/frontends/openid_connect.py @@ -57,9 +57,10 @@ class OpenIDConnectFrontend(FrontendModule): """ def __init__(self, auth_req_callback_func, internal_attributes, conf, base_url, name): - _validate_config(conf) + self._validate_config(conf) super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + self.config = conf provider_config = self.config["provider"] provider_config["issuer"] = base_url diff --git a/src/satosa/frontends/saml2.py b/src/satosa/frontends/saml2.py index cecd533db..4efc38045 100644 --- a/src/satosa/frontends/saml2.py +++ b/src/satosa/frontends/saml2.py @@ -33,6 +33,7 @@ from ..response import Response from ..response import ServiceError from ..saml_util import make_saml_response +from ..saml_util import propagate_logout from satosa.exception import SATOSAError from satosa.exception import SATOSABadRequestError from satosa.exception import SATOSAMissingStateError @@ -67,10 +68,10 @@ class SAMLFrontend(FrontendModule, SAMLBaseModule): KEY_ENDPOINTS = 'endpoints' KEY_IDP_CONFIG = 'idp_config' - def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): + def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name, logout_req_callback_func=None): self._validate_config(config) - super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + super().__init__(auth_req_callback_func, internal_attributes, base_url, name, logout_req_callback_func) self.config = self.init_config(config) self.endpoints = config[self.KEY_ENDPOINTS] @@ -101,6 +102,46 @@ def handle_authn_request(self, context, binding_in): """ return self._handle_authn_request(context, binding_in, self.idp) + def handle_logout_message(self, context, binding_in): + """ + This method is bound to the starting endpoint of the logout. + + :type context: satosa.context.Context + :type binding_in: str + :rtype: + + :param context: The current context + :param binding_in: The binding type + :return: + """ + if "SAMLRequest" in context.request: + return self.handle_logout_request(context, binding_in) + elif "SAMLResponse" in context.request: + return self.handle_logout_response(context) + else: + return NotImplementedError() + + def handle_logout_request(self, context, binding_in): + """ + This method is bound to the starting endpoint of the logout. + + :type context: satosa.context.Context + :type binding_in: str + + :param contxt: The current context + :param binding_in: The binding type (http post, http redirect, ..) + :return: response + """ + return self._handle_logout_request(context, binding_in, self.idp) + + def handle_logout_response(self, context): + """ + See super class method satosa.frontends.base.FrontendModule#handle_logout_response + :type context: satosa.context.Context + :type binding_in: str + """ + return self._handle_logout_response(context) + def handle_backend_error(self, exception): """ See super class satosa.frontends.base.FrontendModule @@ -126,6 +167,7 @@ def register_endpoints(self, backend_names): # Create the idp idp_config = IdPConfig().load(copy.deepcopy(self.idp_config)) self.idp = Server(config=idp_config) + self.sp_sessions = {} return self._register_endpoints(backend_names) + url_map def _create_state_data(self, context, resp_args, relay_state): @@ -286,6 +328,115 @@ def _handle_authn_request(self, context, binding_in, idp): context.decorate(Context.KEY_METADATA_STORE, self.idp.metadata) return self.auth_req_callback_func(context, internal_req) + def _handle_logout_request(self, context, binding_in, idp): + """ + :type context: satosa.context.Context + :type binding_in: str + :type idp: saml.server.Server + :rtype: satosa.response.Response + + :param context: The current context + :param binding_in: The pysaml binding type + :param idp: The saml frontend idp server + :return: response + """ + req_info = idp.parse_logout_request(context.request["SAMLRequest"], binding_in) + logout_req = req_info.message + msg = "{}".format(logout_req) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline) + + resp_args = {} + resp_args['name_id'] = logout_req.name_id.text if logout_req.name_id.text else None + resp_args['session_indexes'] = [] + for session_index in logout_req.session_index: + resp_args['session_indexes'].append(session_index.text) + requester = logout_req.issuer.text + requester_name = self._get_sp_display_name(idp, requester) + + context.state[self.name] = self._create_state_data(context, resp_args, + context.request.get("RelayState")) + + name_id_value = logout_req.name_id.text + name_id_format = logout_req.name_id.format + + sign = self.idp_config.get("service", {}).get("idp", {}).get("logout_requests_signed", True) + internal_req = InternalData( + subject_id=name_id_value, + subject_type=name_id_format, + requester=requester, + ) + + sp_sessions = self._sp_session_info(context) + if sp_sessions: + for sp_info in sp_sessions: + for authn_statement in sp_info[1]: + if authn_statement[0].session_index == resp_args["session_indexes"][0]: + continue + else: + try: + binding, slo_destination = self.idp.pick_binding( + "single_logout_service", None, "spsso", entity_id=sp_info[0][0] + ) + + lreq_id, lreq = self.idp.create_logout_request( + destination=slo_destination, + issuer_entity_id=sp_info[0][0], + name_id=NameID(text=sp_info[0][1].text), + session_indexes=[authn_statement[0].session_index], + sign=sign + ) + + http_args = self.idp.apply_binding(binding, "%s" % lreq, slo_destination) + msg = "http_args: {}".format(http_args) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + propagate_logout(binding, http_args) + except: + msg = { + "message": "LogoutRequest Failed", + "error": "Failed to construct the LogoutRequest for SP - {}".format(sp_info[0][0]) + } + logline = lu.LOG_FMT(id=lu.get_session_id(context.state), message=msg) + + # Return logout response to the SP that initiated logout if the logout request doesn't contain + # the element within the element + extensions = logout_req.extensions if logout_req.extensions else None + if extensions is not None: + _extensions = [] + for ext in extensions.extension_elements: + _extensions.append(ext.namespace) + + if "urn:oasis:names:tc:SAML:2.0:protocol:ext:async-slo" not in _extensions: + binding, destination = self.idp.pick_binding( + "single_logout_service", None, "spsso", entity_id=logout_req.issuer.text + ) + logout_resp = self.idp.create_logout_response(logout_req, [binding]) + http_args = self.idp.apply_binding(binding, "%s" % logout_resp, destination) + msg = "http_args: {}".format(http_args) + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + propagate_logout(binding, http_args) + + return self.logout_req_callback_func(context, internal_req) + + def _sp_session_info(self, context): + """ + :type context: satosa.context.Context + :rtype: list[((str, saml2.saml.NameID), [[saml2.saml.AuthnStatement]])] + + :param context: The current context + :return: list of service provider session information + """ + sp_sessions = [] + + session_id = context.state["SESSION_ID"] + if session_id in self.sp_sessions: + for sp in self.sp_sessions[session_id]: + sp_sessions.append( + (sp, self.idp.session_db.get_authn_statements(sp[1]))) + else: + pass + return sp_sessions + def _get_approved_attributes(self, idp, idp_policy, sp_entity_id, state): """ Returns a list of approved attributes @@ -389,6 +540,12 @@ def _handle_authn_response(self, context, internal_response, idp): name_qualifier=None, ) + session_id = context.state["SESSION_ID"] + if session_id not in self.sp_sessions.keys(): + self.sp_sessions[session_id] = [] + + self.sp_sessions[session_id].append((sp_entity_id, name_id)) + msg = "returning attributes {}".format(json.dumps(ava)) logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) logger.debug(logline) @@ -488,6 +645,23 @@ def _handle_authn_response(self, context, internal_response, idp): return make_saml_response(resp_args["binding"], http_args) + def _handle_logout_response(self, context): + """ + See super class method satosa.frontends.base.FrontendModule#handle_logout_response + :type context: satosa.context.Context + :type internal_response: satosa.internal.InternalData + :rtype satosa.response.LogoutResponse + + :param context: the current context + :param internal_response: the internal logout response + :param idp: the saml frontend idp + """ + msg = "Logout Complete" + logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg) + logger.debug(logline, exc_info=True) + status = "200 OK" + return Response(message=msg, status=status) + def _handle_backend_error(self, exception, idp): """ See super class satosa.frontends.base.FrontendModule @@ -560,8 +734,14 @@ def _register_endpoints(self, providers): valid_providers = "{}|^{}".format(valid_providers, provider) valid_providers = valid_providers.lstrip("|") parsed_endp = urlparse(endp) - url_map.append(("(%s)/%s$" % (valid_providers, parsed_endp.path), - functools.partial(self.handle_authn_request, binding_in=binding))) + if endp_category == "single_sign_on_service": + url_map.append(("(%s)/%s$" % (valid_providers, parsed_endp.path), + functools.partial(self.handle_authn_request, binding_in=binding))) + elif endp_category == "single_logout_service": + url_map.append(("(%s)/%s$" % (valid_providers, parsed_endp.path), + functools.partial(self.handle_logout_message, binding_in=binding))) + else: + raise NotImplementedError() if self.expose_entityid_endpoint(): logger.debug("Exposing frontend entity endpoint = {}".format(self.idp.config.entityid)) diff --git a/src/satosa/internal.py b/src/satosa/internal.py index 24de31890..b2fc403c4 100644 --- a/src/satosa/internal.py +++ b/src/satosa/internal.py @@ -111,6 +111,30 @@ def __init__( self.authority = authority +class LogoutInformation(_Datafy): + """ + Class that holds information about the logout + """ + + def __init__( + self, + timestamp=None, + issuer=None, + status=None, + *args, + **kwargs, + ): + """ + :param timestamp: time when the logout was done + :param issuer: where the logout was done + :param status: status of the logout + """ + super().__init__(self, *args, **kwargs) + self.timestamp = timestamp + self.issuer = issuer + self.status = status + + class InternalData(_Datafy): """ A base class for the data carriers between frontends/backends diff --git a/src/satosa/plugin_loader.py b/src/satosa/plugin_loader.py index b7eb4cf46..83c7e72da 100644 --- a/src/satosa/plugin_loader.py +++ b/src/satosa/plugin_loader.py @@ -27,46 +27,55 @@ def prepend_to_import_path(import_paths): del sys.path[0:len(import_paths)] # restore sys.path -def load_backends(config, callback, internal_attributes): +def load_backends(config, auth_callback, internal_attributes, logout_callback=None): """ Load all backend modules specified in the config :type config: satosa.satosa_config.SATOSAConfig - :type callback: + :type auth_callback: + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :type logout_callback: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[string, dict[str, str | list[str]]] :rtype: Sequence[satosa.backends.base.BackendModule] :param config: The configuration of the satosa proxy - :param callback: Function that will be called by the backend after the authentication is done. + :param auth_callback: Function that will be called by the backend after the authentication is done. + :param logout_callback: Function that will be called by the backend after logout is done. :return: A list of backend modules """ backend_modules = _load_plugins( config.get("CUSTOM_PLUGIN_MODULE_PATHS"), config["BACKEND_MODULES"], backend_filter, config["BASE"], - internal_attributes, callback) + internal_attributes, auth_callback, + logout_callback + ) logger.info("Setup backends: {}".format([backend.name for backend in backend_modules])) return backend_modules -def load_frontends(config, callback, internal_attributes): +def load_frontends(config, auth_callback, internal_attributes, logout_callback=None): """ Load all frontend modules specified in the config :type config: satosa.satosa_config.SATOSAConfig - :type callback: + :type auth_callback: + (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response + :type logout_callback: (satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response :type internal_attributes: dict[string, dict[str, str | list[str]]] :rtype: Sequence[satosa.frontends.base.FrontendModule] :param config: The configuration of the satosa proxy - :param callback: Function that will be called by the frontend after the authentication request + :param auth_callback: Function that will be called by the frontend after the authentication request + :param logout_callback: Function that will be called by the frontend after the logout request has been processed. :return: A list of frontend modules """ frontend_modules = _load_plugins(config.get("CUSTOM_PLUGIN_MODULE_PATHS"), config["FRONTEND_MODULES"], - frontend_filter, config["BASE"], internal_attributes, callback) + frontend_filter, config["BASE"], internal_attributes, auth_callback, + logout_callback) logger.info("Setup frontends: {}".format([frontend.name for frontend in frontend_modules])) return frontend_modules @@ -151,7 +160,7 @@ def _load_plugin_config(config): raise SATOSAConfigurationError("The configuration is corrupt.") from exc -def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attributes, callback): +def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attributes, auth_callback, logout_callback=None): """ Loads endpoint plugins @@ -178,8 +187,8 @@ def _load_plugins(plugin_paths, plugins, plugin_filter, base_url, internal_attri if module_class: module_config = _replace_variables_in_plugin_module_config(plugin_config["config"], base_url, plugin_config["name"]) - instance = module_class(callback, internal_attributes, module_config, base_url, - plugin_config["name"]) + instance = module_class(auth_callback, internal_attributes, module_config, base_url, + plugin_config["name"], logout_callback) loaded_plugin_modules.append(instance) return loaded_plugin_modules @@ -280,3 +289,31 @@ def load_response_microservices(plugin_path, plugins, internal_attributes, base_ base_url) logger.info("Loaded response micro services:{}".format([type(k).__name__ for k in response_services])) return response_services + + +def load_database(config): + """ + Loads the storage database specifies in the config + + :type config: satosa.satosa_config.SATOSAConfig + + :param config: The configuration of the satosa proxy + """ + try: + db = config["DATABASE"]["name"] + except SATOSAConfigurationError as err: + logger.error(err) + if db == "memory": + from satosa.store import SessionStorage + return SessionStorage(config) + elif db == "mongodb": + from satosa.store import SessionStorageMDB + return SessionStorageMDB(config) + elif db == "postgresql": + from satosa.store import SessionStoragePDB + try: + return SessionStoragePDB(config) + except Exception as error: + return error + else: + raise NotImplementedError() diff --git a/src/satosa/saml_util.py b/src/satosa/saml_util.py index fced07568..834d3420e 100644 --- a/src/satosa/saml_util.py +++ b/src/satosa/saml_util.py @@ -1,4 +1,7 @@ +import requests + from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_SOAP from .response import SeeOther, Response @@ -15,3 +18,31 @@ def make_saml_response(binding, http_args): return SeeOther(str(headers["Location"])) return Response(http_args["data"], headers=http_args["headers"]) + + +def propagate_logout(binding, http_args): + """ + :param binding: SAML response binding + :param http_args: HTTP arguments + + :type binding: str + :type http_args: dict + """ + try: + if binding == BINDING_HTTP_REDIRECT: + headers = dict(http_args["headers"]) + requests.get(url=headers["Location"]) + elif binding == BINDING_SOAP: + requests.post( + url=http_args["url"], + headers={"Content-type": "text/xml"}, + data=http_args['data'] + ) + else: + requests.post( + url=http_args['url'], + headers=headers, + data=http_args['data'] + ) + except requests.exceptions.RequestException as err: + print("Error: {}".format(err)) diff --git a/src/satosa/store.py b/src/satosa/store.py new file mode 100644 index 000000000..301abcb6a --- /dev/null +++ b/src/satosa/store.py @@ -0,0 +1,91 @@ +class Storage: + def __init__(self, config): + self.db_config = config["DATABASE"] + + +class SessionStorage(Storage): + """ + In-memory storage + """ + def __init__(self, config): + super().__init__(config) + self.authn_responses = {} + + def store_authn_resp(self, state, internal_resp): + self.authn_responses[state["SESSION_ID"]] = internal_resp.to_dict() + + def get_authn_resp(self, state): + return self.authn_responses.get(state["SESSION_ID"]) + + def delete_session(self, state): + if self.authn_responses.get(state["SESSION_ID"]): + del self.authn_responses[state["SESSION_ID"]] + + +from sqlalchemy.ext.declarative import declarative_base + + +Base = declarative_base() + +class AuthnResponse(Base): + from sqlalchemy.dialects.postgresql import JSON + from sqlalchemy import Column, Integer, String + + __tablename__ = 'authn_responses' + id = Column(Integer, primary_key=True, autoincrement=True) + session_id = Column(String) + authn_response = Column(JSON) + + +class SessionStoragePDB(Storage): + """ + PostgreSQL storage + """ + + def __init__(self, config): + super().__init__(config) + + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + HOST = self.db_config["host"] + PORT = self.db_config["port"] + DB_NAME = self.db_config["db_name"] + USER = self.db_config["user"] + PWD = self.db_config["password"] + + engine = create_engine("postgresql://{USER}:{PWD}@{HOST}:{PORT}/{DB_NAME}".format( + USER=USER, + PWD=PWD, + HOST=HOST, + PORT=PORT, + DB_NAME=DB_NAME + )) + Base.metadata.create_all(engine) + self.Session = sessionmaker(bind=engine) + + def store_authn_resp(self, state, internal_resp): + session = self.Session() + auth_response = AuthnResponse( + session_id=state["SESSION_ID"], + authn_response=(internal_resp.to_dict()) + ) + session.add(auth_response) + session.commit() + session.close() + + def get_authn_resp(self, state): + session = self.Session() + authn_response = session.query(AuthnResponse).filter( + AuthnResponse.session_id == state["SESSION_ID"]).all() + session.close() + if not authn_response: + return None + authn_response = vars(authn_response[-1])["authn_response"] + return authn_response + + def delete_session(self, state): + session = self.Session() + session.query(AuthnResponse).filter(AuthnResponse.session_id == state["SESSION_ID"]).delete() + session.commit() + session.close() diff --git a/tests/conftest.py b/tests/conftest.py index f0602a028..ae5de7275 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,7 +47,10 @@ def sp_conf(cert_and_key): "assertion_consumer_service": [ ("%s/acs/redirect" % sp_base, BINDING_HTTP_REDIRECT) ], - "discovery_response": [("%s/disco" % sp_base, BINDING_DISCO)] + "discovery_response": [("%s/disco" % sp_base, BINDING_DISCO)], + "single_logout_service": [ + ("%s/sp/slo/redirect" % sp_base, BINDING_HTTP_REDIRECT), + ("%s/sp/slo/post" % sp_base, BINDING_HTTP_POST)] }, "want_response_signed": False, "allow_unsolicited": True, @@ -76,6 +79,10 @@ def idp_conf(cert_and_key): "single_sign_on_service": [ ("%s/sso/redirect" % idp_base, BINDING_HTTP_REDIRECT), ], + "single_logout_service": [ + ("%s/slo/redirect" % idp_base, BINDING_HTTP_REDIRECT), + ("%s/slo/post" % idp_base, BINDING_HTTP_POST) + ] }, "policy": { "default": { @@ -95,6 +102,7 @@ def idp_conf(cert_and_key): "logo": [{"text": "https://idp.example.com/static/logo.png", "width": "120", "height": "60", "lang": "en"}], }, + "session_storage": "memory" }, }, "cert_file": cert_and_key[0], @@ -135,6 +143,7 @@ def satosa_config_dict(backend_plugin_config, frontend_plugin_config, request_mi "CUSTOM_PLUGIN_MODULE_PATHS": [os.path.dirname(__file__)], "BACKEND_MODULES": [backend_plugin_config], "FRONTEND_MODULES": [frontend_plugin_config], + "DATABASE": {"name": "memory"}, "MICRO_SERVICES": [request_microservice_config, response_microservice_config], "LOGGING": {"version": 1} } @@ -191,7 +200,8 @@ def saml_frontend_config(cert_and_key, sp_conf): "service": { "idp": { "endpoints": { - "single_sign_on_service": [] + "single_sign_on_service": [], + "single_logout_service": [] }, "name": "Frontend IdP", "name_id_format": NAMEID_FORMAT_TRANSIENT, @@ -225,7 +235,9 @@ def saml_frontend_config(cert_and_key, sp_conf): "endpoints": { "single_sign_on_service": {BINDING_HTTP_POST: "sso/post", - BINDING_HTTP_REDIRECT: "sso/redirect"} + BINDING_HTTP_REDIRECT: "sso/redirect"}, + "single_logout_service": {BINDING_HTTP_REDIRECT: "slo/redirect", + BINDING_HTTP_POST: "slo/post"} } } } @@ -256,8 +268,11 @@ def saml_backend_config(idp_conf): "endpoints": { "assertion_consumer_service": [ ("{}/{}/acs/redirect".format(BASE_URL, name), BINDING_HTTP_REDIRECT)], - "discovery_response": [("{}/disco", BINDING_DISCO)] - + "discovery_response": [("{}/disco", BINDING_DISCO)], + "single_logout_service": [ + ("{}/{}/sp/slo/redirect".format(BASE_URL, name), BINDING_HTTP_REDIRECT), + ("{}/{}/sp/slo/post".format(BASE_URL, name), BINDING_HTTP_POST) + ] } } }, diff --git a/tests/satosa/backends/test_saml2.py b/tests/satosa/backends/test_saml2.py index e1cc96466..df35ff639 100644 --- a/tests/satosa/backends/test_saml2.py +++ b/tests/satosa/backends/test_saml2.py @@ -91,7 +91,8 @@ def create_backend(self, sp_conf, idp_conf): self.samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, "base_url", - "samlbackend") + "samlbackend", + Mock()) def test_register_endpoints(self, sp_conf): """ @@ -172,7 +173,7 @@ def test_start_auth_redirects_directly_to_mirrored_idp( def test_redirect_to_idp_if_only_one_idp_in_metadata(self, context, sp_conf, idp_conf): sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] # instantiate new backend, without any discovery service configured - samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend") + samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend", None) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) @@ -217,6 +218,7 @@ def _make_authn_request(self, http_host, context, config, entity_id): config, "base_url", "samlbackend", + Mock() ) resp = self.samlbackend.authn_request(context, entity_id) req_params = dict(parse_qsl(urlparse(resp.message).query)) @@ -335,6 +337,7 @@ def test_authn_response_with_encrypted_assertion(self, sp_conf, context): {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, "base_url", "samlbackend", + Mock() ) response_binding = BINDING_HTTP_REDIRECT relay_state = "test relay state" @@ -370,7 +373,7 @@ def test_backend_reads_encryption_key_from_key_file(self, sp_conf): sp_conf["key_file"] = os.path.join(TEST_RESOURCE_BASE_PATH, "encryption_key.pem") samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, - "base_url", "samlbackend") + "base_url", "samlbackend", Mock()) assert samlbackend.encryption_keys def test_backend_reads_encryption_key_from_encryption_keypair(self, sp_conf): @@ -378,7 +381,7 @@ def test_backend_reads_encryption_key_from_encryption_keypair(self, sp_conf): sp_conf["encryption_keypairs"] = [{"key_file": os.path.join(TEST_RESOURCE_BASE_PATH, "encryption_key.pem")}] samlbackend = SAMLBackend(Mock(), INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL}, - "base_url", "samlbackend") + "base_url", "samlbackend", Mock()) assert samlbackend.encryption_keys def test_metadata_endpoint(self, context, sp_conf): @@ -390,7 +393,7 @@ def test_metadata_endpoint(self, context, sp_conf): def test_get_metadata_desc(self, sp_conf, idp_conf): sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] # instantiate new backend, with a single backing IdP - samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend") + samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend", None) entity_descriptions = samlbackend.get_metadata_desc() assert len(entity_descriptions) == 1 @@ -417,7 +420,7 @@ def test_get_metadata_desc_with_logo_without_lang(self, sp_conf, idp_conf): sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] # instantiate new backend, with a single backing IdP - samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend") + samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf}, "base_url", "saml_backend", None) entity_descriptions = samlbackend.get_metadata_desc() assert len(entity_descriptions) == 1 @@ -446,7 +449,7 @@ def test_default_redirect_to_discovery_service_if_using_mdq( sp_conf["metadata"]["inline"] = [create_metadata_from_config_dict(idp_conf)] sp_conf["metadata"]["mdq"] = ["https://mdq.example.com"] samlbackend = SAMLBackend(None, INTERNAL_ATTRIBUTES, {"sp_config": sp_conf, "disco_srv": DISCOSRV_URL,}, - "base_url", "saml_backend") + "base_url", "saml_backend", None) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) @@ -462,21 +465,21 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_not_se SAMLBackend.KEY_MEMORIZE_IDP: True, } samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) context.state[Context.KEY_MEMORIZED_IDP] = idp_conf["entityid"] samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) backend_conf[SAMLBackend.KEY_MEMORIZE_IDP] = False samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) @@ -485,7 +488,7 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_not_se context.state[Context.KEY_MEMORIZED_IDP] = idp_conf["entityid"] backend_conf[SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN] = True samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) @@ -506,14 +509,14 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_set_tr SAMLBackend.KEY_MIRROR_FORCE_AUTHN: True, } samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) backend_conf[SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN] = True samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) @@ -534,14 +537,14 @@ def test_use_of_disco_or_redirect_to_idp_when_using_mdq_and_forceauthn_is_set_1( SAMLBackend.KEY_MIRROR_FORCE_AUTHN: True, } samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_discovery_server(resp, sp_conf, DISCOSRV_URL) backend_conf[SAMLBackend.KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN] = True samlbackend = SAMLBackend( - None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend" + None, INTERNAL_ATTRIBUTES, backend_conf, "base_url", "saml_backend", None ) resp = samlbackend.start_auth(context, InternalData()) assert_redirect_to_idp(resp, idp_conf) diff --git a/tests/satosa/frontends/test_openid_connect.py b/tests/satosa/frontends/test_openid_connect.py index f769b2c66..ce1eec8aa 100644 --- a/tests/satosa/frontends/test_openid_connect.py +++ b/tests/satosa/frontends/test_openid_connect.py @@ -88,7 +88,8 @@ def frontend_config_with_extra_id_token_claims(self, signing_key_path): def create_frontend(self, frontend_config): # will use in-memory storage - instance = OpenIDConnectFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, + instance = OpenIDConnectFrontend(lambda ctx, req: None, + INTERNAL_ATTRIBUTES, frontend_config, BASE_URL, "oidc_frontend") instance.register_endpoints(["foo_backend"]) return instance diff --git a/tests/satosa/frontends/test_saml2.py b/tests/satosa/frontends/test_saml2.py index 978489429..b56b1bbef 100644 --- a/tests/satosa/frontends/test_saml2.py +++ b/tests/satosa/frontends/test_saml2.py @@ -44,7 +44,9 @@ } ENDPOINTS = {"single_sign_on_service": {BINDING_HTTP_REDIRECT: "sso/redirect", - BINDING_HTTP_POST: "sso/post"}} + BINDING_HTTP_POST: "sso/post"}, + "single_logout_service": {BINDING_HTTP_REDIRECT: "slo/redirect", + BINDING_HTTP_POST: "slo/post"}} BASE_URL = "https://satosa-idp.example.com" @@ -69,7 +71,8 @@ def setup_for_authn_req(self, context, idp_conf, sp_conf, nameid_format=None, re base_url = self.construct_base_url_from_entity_id(idp_conf["entityid"]) samlfrontend = SAMLFrontend(lambda ctx, internal_req: (ctx, internal_req), - internal_attributes, config, base_url, "saml_frontend") + internal_attributes, config, base_url, "saml_frontend", + lambda ctx, internal_logout_req: (ctx, internal_logout_req)) samlfrontend.register_endpoints(["saml"]) idp_metadata_str = create_metadata_from_config_dict(samlfrontend.idp_config) @@ -119,7 +122,9 @@ def get_auth_response(self, samlfrontend, context, internal_response, sp_conf, i ]) def test_config_error_handling(self, conf): with pytest.raises(ValueError): - SAMLFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend") + SAMLFrontend(lambda ctx, req: None, + INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend", + lambda ctx, req: None) def test_register_endpoints(self, idp_conf): """ @@ -133,7 +138,8 @@ def get_path_from_url(url): base_url = self.construct_base_url_from_entity_id(idp_conf["entityid"]) samlfrontend = SAMLFrontend(lambda context, internal_req: (context, internal_req), - INTERNAL_ATTRIBUTES, config, base_url, "saml_frontend") + INTERNAL_ATTRIBUTES, config, base_url, "saml_frontend", + lambda context, internal_logout_req: (context, internal_logout_req)) providers = ["foo", "bar"] url_map = samlfrontend.register_endpoints(providers) @@ -247,7 +253,7 @@ def test_get_filter_attributes_with_sp_requested_attributes_without_friendlyname "eduPersonAffiliation", "mail", "displayName", "sn", "givenName"]}} # no op mapping for saml attribute names - samlfrontend = SAMLFrontend(None, internal_attributes, conf, base_url, "saml_frontend") + samlfrontend = SAMLFrontend(None, internal_attributes, conf, base_url, "saml_frontend", None) samlfrontend.register_endpoints(["testprovider"]) internal_req = InternalData( @@ -357,7 +363,9 @@ def test_sp_metadata_without_uiinfo(self, context, idp_conf, sp_conf): def test_metadata_endpoint(self, context, idp_conf): conf = {"idp_config": idp_conf, "endpoints": ENDPOINTS} - samlfrontend = SAMLFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend") + samlfrontend = SAMLFrontend(lambda ctx, req: None, + INTERNAL_ATTRIBUTES, conf, "base_url", "saml_frontend", + lambda ctx, req: None) samlfrontend.register_endpoints(["todo"]) resp = samlfrontend._metadata_endpoint(context) headers = dict(resp.headers) @@ -399,8 +407,10 @@ class TestSAMLMirrorFrontend: @pytest.fixture(autouse=True) def create_frontend(self, idp_conf): conf = {"idp_config": idp_conf, "endpoints": ENDPOINTS} - self.frontend = SAMLMirrorFrontend(lambda ctx, req: None, INTERNAL_ATTRIBUTES, conf, BASE_URL, - "saml_mirror_frontend") + self.frontend = SAMLMirrorFrontend(lambda ctx, req: None, + INTERNAL_ATTRIBUTES, conf, BASE_URL, + "saml_mirror_frontend", + lambda ctx, req: None) self.frontend.register_endpoints([self.BACKEND]) def assert_dynamic_endpoints(self, sso_endpoints): @@ -493,7 +503,8 @@ def frontend(self, idp_conf, sp_conf): internal_attributes, conf, BASE_URL, - "saml_virtual_co_frontend") + "saml_virtual_co_frontend", + ) frontend.register_endpoints([self.BACKEND]) return frontend diff --git a/tests/satosa/metadata_creation/test_saml_metadata.py b/tests/satosa/metadata_creation/test_saml_metadata.py index 77e8ac1d7..68f999a7e 100644 --- a/tests/satosa/metadata_creation/test_saml_metadata.py +++ b/tests/satosa/metadata_creation/test_saml_metadata.py @@ -43,6 +43,18 @@ def assert_single_sign_on_endpoints_for_saml_mirror_frontend(self, entity_descri expected_url = "{}/{}/{}/{}".format(BASE_URL, backend_name, encoded_target_entity_id, path) assert expected_url in sso_urls_for_binding + def assert_single_logout_endpoints_for_saml_frontend(self, entity_descriptor, saml_frontend_config, backend_names): + metadata = InMemoryMetaData(None, str(entity_descriptor)) + metadata.load() + slo = metadata.service(saml_frontend_config["config"]["idp_config"]["entityid"], "idpsso_descriptor", + "single_logout_service") + + for backend_name in backend_names: + for binding, path in saml_frontend_config["config"]["endpoints"]["single_logout_service"].items(): + slo_urls_for_binding = [endpoint["location"] for endpoint in slo[binding]] + expected_url = "{}/{}/{}".format(BASE_URL, backend_name, path) + assert expected_url in slo_urls_for_binding + def assert_assertion_consumer_service_endpoints_for_saml_backend(self, entity_descriptor, saml_backend_config): metadata = InMemoryMetaData(None, str(entity_descriptor)) metadata.load() @@ -52,6 +64,15 @@ def assert_assertion_consumer_service_endpoints_for_saml_backend(self, entity_de "assertion_consumer_service"]: assert acs[binding][0]["location"] == url + def assert_single_logout_endpoints_for_saml_backend(self, entity_descriptor, saml_backend_config): + metadata = InMemoryMetaData(None, str(entity_descriptor)) + metadata.load() + slo = metadata.service(saml_backend_config["config"]["sp_config"]["entityid"], "spsso_descriptor", + "single_logout_service") + for url, binding in saml_backend_config["config"]["sp_config"]["service"]["sp"]["endpoints"][ + "single_logout_service"]: + assert slo[binding][0]["location"] == url + def test_saml_frontend_with_saml_backend(self, satosa_config_dict, saml_frontend_config, saml_backend_config): satosa_config_dict["FRONTEND_MODULES"] = [saml_frontend_config] satosa_config_dict["BACKEND_MODULES"] = [saml_backend_config] @@ -63,10 +84,15 @@ def test_saml_frontend_with_saml_backend(self, satosa_config_dict, saml_frontend entity_descriptor = frontend_metadata[saml_frontend_config["name"]][0] self.assert_single_sign_on_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, [saml_backend_config["name"]]) + self.assert_single_logout_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, + [saml_backend_config["name"]]) assert len(backend_metadata) == 1 self.assert_assertion_consumer_service_endpoints_for_saml_backend( backend_metadata[saml_backend_config["name"]][0], saml_backend_config) + self.assert_single_logout_endpoints_for_saml_backend( + backend_metadata[saml_backend_config["name"]][0], + saml_backend_config) def test_saml_frontend_with_oidc_backend(self, satosa_config_dict, saml_frontend_config, oidc_backend_config): satosa_config_dict["FRONTEND_MODULES"] = [saml_frontend_config] @@ -79,6 +105,8 @@ def test_saml_frontend_with_oidc_backend(self, satosa_config_dict, saml_frontend entity_descriptor = frontend_metadata[saml_frontend_config["name"]][0] self.assert_single_sign_on_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, [oidc_backend_config["name"]]) + self.assert_single_logout_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, + [oidc_backend_config["name"]]) # OIDC backend does not produce any SAML metadata assert not backend_metadata @@ -95,11 +123,16 @@ def test_saml_frontend_with_multiple_backends(self, satosa_config_dict, saml_fro self.assert_single_sign_on_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, [saml_backend_config["name"], oidc_backend_config["name"]]) + self.assert_single_logout_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, + [oidc_backend_config["name"]]) # only the SAML backend produces SAML metadata assert len(backend_metadata) == 1 self.assert_assertion_consumer_service_endpoints_for_saml_backend( backend_metadata[saml_backend_config["name"]][0], saml_backend_config) + self.assert_single_logout_endpoints_for_saml_backend( + backend_metadata[saml_backend_config["name"]][0], + saml_backend_config) def test_saml_mirror_frontend_with_saml_backend_with_multiple_target_providers(self, satosa_config_dict, idp_conf, saml_mirror_frontend_config, @@ -127,6 +160,9 @@ def test_saml_mirror_frontend_with_saml_backend_with_multiple_target_providers(s self.assert_assertion_consumer_service_endpoints_for_saml_backend( backend_metadata[saml_backend_config["name"]][0], saml_backend_config) + self.assert_single_logout_endpoints_for_saml_backend( + backend_metadata[saml_backend_config["name"]][0], + saml_backend_config) def test_saml_mirror_frontend_with_oidc_backend(self, satosa_config_dict, saml_mirror_frontend_config, oidc_backend_config): @@ -173,6 +209,9 @@ def test_saml_mirror_frontend_with_multiple_backends(self, satosa_config_dict, i self.assert_assertion_consumer_service_endpoints_for_saml_backend( backend_metadata[saml_backend_config["name"]][0], saml_backend_config) + self.assert_single_logout_endpoints_for_saml_backend( + backend_metadata[saml_backend_config["name"]][0], + saml_backend_config) def test_two_saml_frontends(self, satosa_config_dict, saml_frontend_config, saml_mirror_frontend_config, oidc_backend_config): @@ -189,6 +228,8 @@ def test_two_saml_frontends(self, satosa_config_dict, saml_frontend_config, saml entity_descriptor = saml_entities[0] self.assert_single_sign_on_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, [oidc_backend_config["name"]]) + self.assert_single_logout_endpoints_for_saml_frontend(entity_descriptor, saml_frontend_config, + [oidc_backend_config["name"]]) mirrored_saml_entities = frontend_metadata[saml_mirror_frontend_config["name"]] assert len(mirrored_saml_entities) == 1 diff --git a/tests/satosa/test_routing.py b/tests/satosa/test_routing.py index be23456ad..9d9f160ce 100644 --- a/tests/satosa/test_routing.py +++ b/tests/satosa/test_routing.py @@ -13,11 +13,11 @@ class TestModuleRouter: def create_router(self): backends = [] for provider in BACKEND_NAMES: - backends.append(TestBackend(None, {"attributes": {}}, None, None, provider)) + backends.append(TestBackend(None, {"attributes": {}}, None, None, provider, None)) frontends = [] for receiver in FRONTEND_NAMES: - frontends.append(TestFrontend(None, {"attributes": {}}, None, None, receiver)) + frontends.append(TestFrontend(None, {"attributes": {}}, None, None, receiver, None)) request_micro_service_name = "RequestService" response_micro_service_name = "ResponseService" diff --git a/tests/util.py b/tests/util.py index c26c796fe..800a2f8da 100644 --- a/tests/util.py +++ b/tests/util.py @@ -410,13 +410,17 @@ class FakeFrontend(FrontendModule): TODO comment """ - def __init__(self, handle_authn_request_func=None, internal_attributes=None, + def __init__(self, handle_authn_request_func=None, handle_logout_request_func=None, + internal_attributes=None, base_url="", name="FakeFrontend", handle_authn_response_func=None, + handle_logout_response_func=None, register_endpoints_func=None): super().__init__(None, internal_attributes, base_url, name) self.handle_authn_request_func = handle_authn_request_func self.handle_authn_response_func = handle_authn_response_func + self.handle_logout_request_func = handle_logout_request_func + self.handle_logout_response_func = handle_logout_response_func self.register_endpoints_func = register_endpoints_func def handle_authn_request(self, context, binding_in): @@ -458,8 +462,8 @@ def register_endpoints(self, backend_names): class TestBackend(BackendModule): __test__ = False - def __init__(self, auth_callback_func, internal_attributes, config, base_url, name): - super().__init__(auth_callback_func, internal_attributes, base_url, name) + def __init__(self, auth_callback_func, logout_callback_func, internal_attributes, config, base_url, name): + super().__init__(auth_callback_func, logout_callback_func, internal_attributes, base_url, name) def register_endpoints(self): return [("^{}/response$".format(self.name), self.handle_response)] @@ -478,8 +482,8 @@ def handle_response(self, context): class TestFrontend(FrontendModule): __test__ = False - def __init__(self, auth_req_callback_func, internal_attributes, config, base_url, name): - super().__init__(auth_req_callback_func, internal_attributes, base_url, name) + def __init__(self, auth_req_callback_func, logout_callback_func, internal_attributes, config, base_url, name): + super().__init__(auth_req_callback_func, logout_callback_func, internal_attributes, base_url, name) def register_endpoints(self, backend_names): url_map = [("^{}/{}/request$".format(p, self.name), self.handle_request) for p in backend_names]