Exemple #1
0
    def __init__(self, outgoing, internal_attributes, config):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        """
        super(SamlBackend, self).__init__(outgoing, internal_attributes)
        sp_config = SPConfig().load(copy.deepcopy(config["config"]), False)

        self.sp = Base(sp_config)
        self.idp_disco_query_param = "entityID"
        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = None
        self.state_id = config["state_id"]
        try:
            self.discosrv = config["disco_srv"]
        except KeyError:
            pass
Exemple #2
0
    def __init__(self, outgoing, internal_attributes, config):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        """
        super(SamlBackend, self).__init__(outgoing, internal_attributes)
        sp_config = SPConfig().load(copy.deepcopy(config["config"]), False)

        self.sp = Base(sp_config)
        self.idp_disco_query_param = "entityID"
        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = None
        self.state_id = config["state_id"]
        try:
            self.discosrv = config["disco_srv"]
        except KeyError:
            pass
Exemple #3
0
    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
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        sp_config = SPConfig().load(
            copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG]), False)
        self.sp = Base(sp_config)

        self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV)
        self.encryption_keys = []
        self.outstanding_queries = {}
        self.idp_blacklist_file = config.get('idp_blacklist_file', None)
        self.requested_attributes = self.config.get(
            SAMLBackend.KEY_DYNAMIC_REQUESTED_ATTRIBUTES)

        sp_keypairs = sp_config.getattr('encryption_keypairs', '')
        sp_key_file = sp_config.getattr('key_file', '')
        if sp_keypairs:
            key_file_paths = [pair['key_file'] for pair in sp_keypairs]
        elif sp_key_file:
            key_file_paths = [sp_key_file]
        else:
            key_file_paths = []

        for p in key_file_paths:
            with open(p) as key_file:
                self.encryption_keys.append(key_file.read())
Exemple #4
0
    def __init__(self, outgoing, internal_attributes, config, base_url, name):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]
        :type base_url: str
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)

        sp_config = SPConfig().load(copy.deepcopy(config["sp_config"]), False)
        self.sp = Base(sp_config)

        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = config.get("disco_srv")
        self.encryption_keys = []

        key_file_paths = None
        if 'encryption_keypairs' in self.config[
                'sp_config']:  # prioritize explicit encryption keypairs
            key_file_paths = [
                keypair['key_file']
                for keypair in self.config['sp_config']['encryption_keypairs']
            ]
        elif 'key_file' in self.config['sp_config']:
            key_file_paths = [self.config['sp_config']['key_file']]

        if key_file_paths:
            for p in key_file_paths:
                with open(p) as key_file:
                    self.encryption_keys.append(key_file.read())
Exemple #5
0
 def __init__(self,
              environ,
              start_response,
              config,
              cache=None,
              outgoing=None,
              discosrv=None,
              bindings=None):
     service.Service.__init__(self, environ, start_response)
     self.sp = Base(config, state_cache=cache)
     self.environ = environ
     self.start_response = start_response
     self.cache = cache
     self.idp_disco_query_param = "entityID"
     self.outgoing = outgoing
     self.discosrv = discosrv
     if bindings:
         self.bindings = bindings
     else:
         self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
     logger.debug("--- SSO ---")
Exemple #6
0
 def __init__(self, environ, start_response, config, cache=None,
              outgoing=None, discosrv=None, bindings=None):
     service.Service.__init__(self, environ, start_response)
     self.sp = Base(config, state_cache=cache)
     self.environ = environ
     self.start_response = start_response
     self.cache = cache
     self.idp_disco_query_param = "entityID"
     self.outgoing = outgoing
     self.discosrv = discosrv
     if bindings:
         self.bindings = bindings
     else:
         self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
     logger.debug("--- SSO ---")
Exemple #7
0
    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
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False)
        self.sp = Base(sp_config)

        self.discosrv = config.get(self.KEY_DISCO_SRV)
        self.encryption_keys = []
        self.outstanding_queries = {}
        self.idp_blacklist_file = config.get('idp_blacklist_file', None)

        sp_keypairs = sp_config.getattr('encryption_keypairs', '')
        sp_key_file = sp_config.getattr('key_file', '')
        if sp_keypairs:
            key_file_paths = [pair['key_file'] for pair in sp_keypairs]
        elif sp_key_file:
            key_file_paths = [sp_key_file]
        else:
            key_file_paths = []

        for p in key_file_paths:
            with open(p) as key_file:
                self.encryption_keys.append(key_file.read())
Exemple #8
0
    def __init__(self, outgoing, internal_attributes, config, base_url, name):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]
        :type base_url: str
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)

        sp_config = SPConfig().load(copy.deepcopy(config["sp_config"]), False)
        self.sp = Base(sp_config)

        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = config.get("disco_srv")
        self.encryption_keys = []

        key_file_paths = None
        if 'encryption_keypairs' in self.config['sp_config']: # prioritize explicit encryption keypairs
            key_file_paths = [keypair['key_file'] for keypair in self.config['sp_config']['encryption_keypairs']]
        elif 'key_file' in self.config['sp_config']:
            key_file_paths = [self.config['sp_config']['key_file']]

        if key_file_paths:
            for p in key_file_paths:
                with open(p) as key_file:
                    self.encryption_keys.append(key_file.read())
Exemple #9
0
class SAMLBackend(BackendModule, SAMLBaseModule):
    """
    A saml2 backend module (acting as a SP).
    """
    KEY_DISCO_SRV = 'disco_srv'
    KEY_SAML_DISCOVERY_SERVICE_URL = 'saml_discovery_service_url'
    KEY_SAML_DISCOVERY_SERVICE_POLICY = 'saml_discovery_service_policy'
    KEY_SP_CONFIG = 'sp_config'
    KEY_MIRROR_FORCE_AUTHN = 'mirror_force_authn'
    KEY_MEMORIZE_IDP = 'memorize_idp'
    KEY_USE_MEMORIZED_IDP_WHEN_FORCE_AUTHN = 'use_memorized_idp_when_force_authn'

    VALUE_ACR_COMPARISON_DEFAULT = 'exact'

    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
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        sp_config = SPConfig().load(
            copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG]), False)
        self.sp = Base(sp_config)

        self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV)
        self.encryption_keys = []
        self.outstanding_queries = {}
        self.idp_blacklist_file = config.get('idp_blacklist_file', None)

        sp_keypairs = sp_config.getattr('encryption_keypairs', '')
        sp_key_file = sp_config.getattr('key_file', '')
        if sp_keypairs:
            key_file_paths = [pair['key_file'] for pair in sp_keypairs]
        elif sp_key_file:
            key_file_paths = [sp_key_file]
        else:
            key_file_paths = []

        for p in key_file_paths:
            with open(p) as key_file:
                self.encryption_keys.append(key_file.read())

    def get_idp_entity_id(self, context):
        """
        :type context: satosa.context.Context
        :rtype: str | None

        :param context: The current context
        :return: the entity_id of the idp or None
        """

        idps = self.sp.metadata.identity_providers()
        only_one_idp_in_metadata = ("mdq"
                                    not in self.config["sp_config"]["metadata"]
                                    and len(idps) == 1)

        only_idp = only_one_idp_in_metadata and idps[0]
        target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID)
        force_authn = get_force_authn(context, self.config, self.sp.config)
        memorized_idp = get_memorized_idp(context, self.config, force_authn)
        entity_id = only_idp or target_entity_id or memorized_idp or None

        msg = {
            "message": "Selected IdP",
            "only_one": only_idp,
            "target_entity_id": target_entity_id,
            "force_authn": force_authn,
            "memorized_idp": memorized_idp,
            "entity_id": entity_id,
        }
        logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state),
                                    message=msg)
        logger.info(logline)
        return entity_id

    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.InternalData
        :rtype: satosa.response.Response
        """

        entity_id = self.get_idp_entity_id(context)
        if entity_id is None:
            # since context is not passed to disco_query
            # keep the information in the state cookie
            context.state[Context.KEY_FORCE_AUTHN] = get_force_authn(
                context, self.config, self.sp.config)
            return self.disco_query(context)

        return self.authn_request(context, entity_id)

    def disco_query(self, context):
        """
        Makes a request to the discovery server

        :type context: satosa.context.Context
        :type internal_req: satosa.internal.InternalData
        :rtype: satosa.response.SeeOther

        :param context: The current context
        :param internal_req: The request
        :return: Response
        """
        endpoints = self.sp.config.getattr("endpoints", "sp")
        return_url = endpoints["discovery_response"][0][0]

        disco_url = (context.get_decoration(
            SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_URL) or self.discosrv)
        disco_policy = context.get_decoration(
            SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_POLICY)

        args = {"return": return_url}
        if disco_policy:
            args["policy"] = disco_policy

        loc = self.sp.create_discovery_service_request(disco_url,
                                                       self.sp.config.entityid,
                                                       **args)
        return SeeOther(loc)

    def construct_requested_authn_context(self, entity_id):
        if not self.acr_mapping:
            return None

        acr_entry = util.get_dict_defaults(self.acr_mapping, entity_id)
        if not acr_entry:
            return None

        if type(acr_entry) is not dict:
            acr_entry = {
                "class_ref": acr_entry,
                "comparison": self.VALUE_ACR_COMPARISON_DEFAULT,
            }

        authn_context = requested_authn_context(
            acr_entry['class_ref'],
            comparison=acr_entry.get('comparison',
                                     self.VALUE_ACR_COMPARISON_DEFAULT))

        return authn_context

    def authn_request(self, context, entity_id):
        """
        Do an authorization request on idp with given entity id.
        This is the start of the authorization.

        :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
        """

        # If IDP blacklisting is enabled and the selected IDP is blacklisted,
        # stop here
        if self.idp_blacklist_file:
            with open(self.idp_blacklist_file) as blacklist_file:
                blacklist_array = json.load(blacklist_file)['blacklist']
                if entity_id in blacklist_array:
                    msg = "IdP with EntityID {} is blacklisted".format(
                        entity_id)
                    logline = lu.LOG_FMT.format(id=lu.get_session_id(
                        context.state),
                                                message=msg)
                    logger.debug(logline, exc_info=False)
                    raise SATOSAAuthenticationError(
                        context.state,
                        "Selected IdP is blacklisted for this backend")

        kwargs = {}
        authn_context = self.construct_requested_authn_context(entity_id)
        if authn_context:
            kwargs["requested_authn_context"] = authn_context
        if self.config.get(SAMLBackend.KEY_MIRROR_FORCE_AUTHN):
            kwargs["force_authn"] = get_force_authn(context, self.config,
                                                    self.sp.config)

        try:
            binding, destination = self.sp.pick_binding(
                "single_sign_on_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)

            acs_endp, response_binding = self.sp.config.getattr(
                "endpoints", "sp")["assertion_consumer_service"][0]
            req_id, req = self.sp.create_authn_request(
                destination, binding=response_binding, **kwargs)
            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 AuthnRequest for state"
            logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state),
                                        message=msg)
            logger.debug(logline, exc_info=True)
            raise SATOSAAuthenticationError(
                context.state, "Failed to construct the AuthnRequest") from exc

        if self.sp.config.getattr('allow_unsolicited', 'sp') is False:
            if req_id in self.outstanding_queries:
                msg = "Request with duplicate id {}".format(req_id)
                logline = lu.LOG_FMT.format(id=lu.get_session_id(
                    context.state),
                                            message=msg)
                logger.debug(logline)
                raise SATOSAAuthenticationError(context.state, msg)
            self.outstanding_queries[req_id] = req

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

    def authn_response(self, context, binding):
        """
        Endpoint for the idp response
        :type context: satosa.context,Context
        :type binding: str
        :rtype: satosa.response.Response

        :param context: The current context
        :param binding: The saml binding type
        :return: response
        """
        if not context.request["SAMLResponse"]:
            msg = "Missing Response for state"
            logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state),
                                        message=msg)
            logger.debug(logline)
            raise SATOSAAuthenticationError(context.state, "Missing Response")

        try:
            authn_response = self.sp.parse_authn_request_response(
                context.request["SAMLResponse"],
                binding,
                outstanding=self.outstanding_queries)
        except Exception as err:
            msg = "Failed to parse authn request for state"
            logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state),
                                        message=msg)
            logger.debug(logline, exc_info=True)
            raise SATOSAAuthenticationError(
                context.state, "Failed to parse authn request") from err

        if self.sp.config.getattr('allow_unsolicited', 'sp') is False:
            req_id = authn_response.in_response_to
            if req_id not in self.outstanding_queries:
                msg = "No request with id: {}".format(req_id),
                logline = lu.LOG_FMT.format(id=lu.get_session_id(
                    context.state),
                                            message=msg)
                logger.debug(logline)
                raise SATOSAAuthenticationError(context.state, msg)
            del self.outstanding_queries[req_id]

        # check if the relay_state matches the cookie state
        if context.state[
                self.name]["relay_state"] != context.request["RelayState"]:
            msg = "State did not match relay state for state"
            logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state),
                                        message=msg)
            logger.debug(logline)
            raise SATOSAAuthenticationError(context.state,
                                            "State did not match relay state")

        context.decorate(Context.KEY_METADATA_STORE, self.sp.metadata)
        if self.config.get(SAMLBackend.KEY_MEMORIZE_IDP):
            issuer = authn_response.response.issuer.text.strip()
            context.state[Context.KEY_MEMORIZED_IDP] = issuer
        context.state.pop(self.name, None)
        context.state.pop(Context.KEY_FORCE_AUTHN, None)
        return self.auth_callback_func(
            context, self._translate_response(authn_response, context.state))

    def disco_response(self, context):
        """
        Endpoint for the discovery server response

        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response
        """
        info = context.request
        state = context.state

        try:
            entity_id = info["entityID"]
        except KeyError as err:
            msg = "No IDP chosen for state"
            logline = lu.LOG_FMT.format(id=lu.get_session_id(state),
                                        message=msg)
            logger.debug(logline, exc_info=True)
            raise SATOSAAuthenticationError(state, "No IDP chosen") from err

        return self.authn_request(context, entity_id)

    def _translate_response(self, response, state):
        """
        Translates a saml authorization response to an internal response

        :type response: saml2.response.AuthnResponse
        :rtype: satosa.internal.InternalData
        :param response: The saml authorization response
        :return: A translated internal response
        """

        # The response may have been encrypted by the IdP so if we have an
        # encryption key, try it.
        if self.encryption_keys:
            response.parse_assertion(self.encryption_keys)

        authn_info = response.authn_info()[0]
        auth_class_ref = authn_info[0]
        timestamp = response.assertion.authn_statement[0].authn_instant
        issuer = response.response.issuer.text

        auth_info = AuthenticationInformation(
            auth_class_ref,
            timestamp,
            issuer,
        )

        # The SAML response may not include a NameID.
        subject = response.get_subject()
        name_id = subject.text if subject else None
        name_id_format = subject.format if subject else None

        attributes = self.converter.to_internal(
            self.attribute_profile,
            response.ava,
        )

        internal_resp = InternalData(
            auth_info=auth_info,
            attributes=attributes,
            subject_type=name_id_format,
            subject_id=name_id,
        )

        msg = "backend received attributes:\n{}".format(
            json.dumps(response.ava, indent=4))
        logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg)
        logger.debug(logline)
        return internal_resp

    def _metadata_endpoint(self, context):
        """
        Endpoint for retrieving the backend metadata
        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response with metadata
        """
        msg = "Sending metadata response"
        logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state),
                                    message=msg)
        logger.debug(logline)

        metadata_string = create_metadata_string(None, self.sp.config, 4, None,
                                                 None, None, None,
                                                 None).decode("utf-8")
        return Response(metadata_string, content="text/xml")

    def register_endpoints(self):
        """
        See super class method satosa.backends.base.BackendModule#register_endpoints
        :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))]
        """
        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            parsed_endp = urlparse(endp)
            url_map.append(("^%s$" % parsed_endp.path[1:],
                            functools.partial(self.authn_response,
                                              binding=binding)))
            if binding == BINDING_HTTP_REDIRECT:
                msg = " ".join([
                    "AssertionConsumerService endpoint with binding",
                    BINDING_HTTP_REDIRECT,
                    "is not recommended.",
                    "Quoting section 4.1.2 of",
                    "'Profiles for the OASIS Security Assertion Markup Language (SAML) V2.0':",
                    "The HTTP Redirect binding MUST NOT be used,",
                    "as the response will typically exceed the URL length",
                    "permitted by most user agents.",
                ])
                _warnings.warn(msg, UserWarning)

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                parsed_endp = urlparse(endp)
                url_map.append(
                    ("^%s$" % parsed_endp.path[1:], self.disco_response))

        if self.expose_entityid_endpoint():
            parsed_entity_id = urlparse(self.sp.config.entityid)
            url_map.append(("^{0}".format(parsed_entity_id.path[1:]),
                            self._metadata_endpoint))

        return url_map

    def get_metadata_desc(self):
        """
        See super class satosa.backends.backend_base.BackendModule#get_metadata_desc
        :rtype: satosa.metadata_creation.description.MetadataDescription
        """
        entity_descriptions = []

        idp_entities = self.sp.metadata.with_descriptor("idpsso")
        for entity_id, entity in idp_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.sp.metadata.extension(
                entity_id, "idpsso_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
Exemple #10
0
class SAMLBackend(BackendModule, SAMLBaseModule):
    """
    A saml2 backend module (acting as a SP).
    """
    KEY_DISCO_SRV = 'disco_srv'
    KEY_SP_CONFIG = 'sp_config'
    VALUE_ACR_COMPARISON_DEFAULT = 'exact'

    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
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)
        self.config = self.init_config(config)

        sp_config = SPConfig().load(copy.deepcopy(config[self.KEY_SP_CONFIG]), False)
        self.sp = Base(sp_config)

        self.discosrv = config.get(self.KEY_DISCO_SRV)
        self.encryption_keys = []
        self.outstanding_queries = {}
        self.idp_blacklist_file = config.get('idp_blacklist_file', None)

        sp_keypairs = sp_config.getattr('encryption_keypairs', '')
        sp_key_file = sp_config.getattr('key_file', '')
        if sp_keypairs:
            key_file_paths = [pair['key_file'] for pair in sp_keypairs]
        elif sp_key_file:
            key_file_paths = [sp_key_file]
        else:
            key_file_paths = []

        for p in key_file_paths:
            with open(p) as key_file:
                self.encryption_keys.append(key_file.read())

    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.InternalData
        :rtype: satosa.response.Response
        """

        target_entity_id = context.get_decoration(Context.KEY_TARGET_ENTITYID)
        if target_entity_id:
            entity_id = target_entity_id
            return self.authn_request(context, entity_id)

        # if there is only one IdP in the metadata, bypass the discovery service
        idps = self.sp.metadata.identity_providers()
        if len(idps) == 1 and "mdq" not in self.config["sp_config"]["metadata"]:
            entity_id = idps[0]
            return self.authn_request(context, entity_id)

        return self.disco_query()

    def disco_query(self):
        """
        Makes a request to the discovery server

        :type context: satosa.context.Context
        :type internal_req: satosa.internal.InternalData
        :rtype: satosa.response.SeeOther

        :param context: The current context
        :param internal_req: The request
        :return: Response
        """
        return_url = self.sp.config.getattr("endpoints", "sp")["discovery_response"][0][0]
        loc = self.sp.create_discovery_service_request(self.discosrv, self.sp.config.entityid, **{"return": return_url})
        return SeeOther(loc)

    def construct_requested_authn_context(self, entity_id):
        if not self.acr_mapping:
            return None

        acr_entry = util.get_dict_defaults(self.acr_mapping, entity_id)
        if not acr_entry:
            return None

        if type(acr_entry) is not dict:
            acr_entry = {
                "class_ref": acr_entry,
                "comparison": self.VALUE_ACR_COMPARISON_DEFAULT,
            }

        authn_context = requested_authn_context(
            acr_entry['class_ref'], comparison=acr_entry.get(
                'comparison', self.VALUE_ACR_COMPARISON_DEFAULT))

        return authn_context

    def authn_request(self, context, entity_id):
        """
        Do an authorization request on idp with given entity id.
        This is the start of the authorization.

        :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
        """

        # If IDP blacklisting is enabled and the selected IDP is blacklisted,
        # stop here
        if self.idp_blacklist_file:
            with open(self.idp_blacklist_file) as blacklist_file:
                blacklist_array = json.load(blacklist_file)['blacklist']
                if entity_id in blacklist_array:
                    satosa_logging(logger, logging.DEBUG, "IdP with EntityID {} is blacklisted".format(entity_id), context.state, exc_info=False)
                    raise SATOSAAuthenticationError(context.state, "Selected IdP is blacklisted for this backend")

        kwargs = {}
        authn_context = self.construct_requested_authn_context(entity_id)
        if authn_context:
            kwargs['requested_authn_context'] = authn_context

        try:
            binding, destination = self.sp.pick_binding(
                "single_sign_on_service", None, "idpsso", entity_id=entity_id)
            satosa_logging(logger, logging.DEBUG, "binding: %s, destination: %s" % (binding, destination),
                           context.state)
            acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0]
            req_id, req = self.sp.create_authn_request(
                destination, binding=response_binding, **kwargs)
            relay_state = util.rndstr()
            ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state)
            satosa_logging(logger, logging.DEBUG, "ht_args: %s" % ht_args, context.state)
        except Exception as exc:
            satosa_logging(logger, logging.DEBUG, "Failed to construct the AuthnRequest for state", context.state,
                           exc_info=True)
            raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from exc

        if self.sp.config.getattr('allow_unsolicited', 'sp') is False:
            if req_id in self.outstanding_queries:
                errmsg = "Request with duplicate id {}".format(req_id)
                satosa_logging(logger, logging.DEBUG, errmsg, context.state)
                raise SATOSAAuthenticationError(context.state, errmsg)
            self.outstanding_queries[req_id] = req

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

    def authn_response(self, context, binding):
        """
        Endpoint for the idp response
        :type context: satosa.context,Context
        :type binding: str
        :rtype: satosa.response.Response

        :param context: The current context
        :param binding: The saml binding type
        :return: response
        """
        if not context.request["SAMLResponse"]:
            satosa_logging(logger, logging.DEBUG, "Missing Response for state", context.state)
            raise SATOSAAuthenticationError(context.state, "Missing Response")

        try:
            authn_response = self.sp.parse_authn_request_response(
                context.request["SAMLResponse"],
                binding, outstanding=self.outstanding_queries)
        except Exception as err:
            satosa_logging(logger, logging.DEBUG, "Failed to parse authn request for state", context.state,
                           exc_info=True)
            raise SATOSAAuthenticationError(context.state, "Failed to parse authn request") from err

        if self.sp.config.getattr('allow_unsolicited', 'sp') is False:
            req_id = authn_response.in_response_to
            if req_id not in self.outstanding_queries:
                errmsg = "No request with id: {}".format(req_id),
                satosa_logging(logger, logging.DEBUG, errmsg, context.state)
                raise SATOSAAuthenticationError(context.state, errmsg)
            del self.outstanding_queries[req_id]

        # check if the relay_state matches the cookie state
        if context.state[self.name]["relay_state"] != context.request["RelayState"]:
            satosa_logging(logger, logging.DEBUG,
                           "State did not match relay state for state", context.state)
            raise SATOSAAuthenticationError(context.state, "State did not match relay state")

        context.decorate(Context.KEY_BACKEND_METADATA_STORE, self.sp.metadata)

        del context.state[self.name]
        return self.auth_callback_func(context, self._translate_response(authn_response, context.state))

    def disco_response(self, context):
        """
        Endpoint for the discovery server response

        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response
        """
        info = context.request
        state = context.state

        try:
            entity_id = info["entityID"]
        except KeyError as err:
            satosa_logging(logger, logging.DEBUG, "No IDP chosen for state", state, exc_info=True)
            raise SATOSAAuthenticationError(state, "No IDP chosen") from err

        return self.authn_request(context, entity_id)

    def _translate_response(self, response, state):
        """
        Translates a saml authorization response to an internal response

        :type response: saml2.response.AuthnResponse
        :rtype: satosa.internal.InternalData
        :param response: The saml authorization response
        :return: A translated internal response
        """

        # The response may have been encrypted by the IdP so if we have an
        # encryption key, try it.
        if self.encryption_keys:
            response.parse_assertion(self.encryption_keys)

        authn_info = response.authn_info()[0]
        auth_class_ref = authn_info[0]
        timestamp = response.assertion.authn_statement[0].authn_instant
        issuer = response.response.issuer.text

        auth_info = AuthenticationInformation(
            auth_class_ref, timestamp, issuer,
        )

        # The SAML response may not include a NameID.
        subject = response.get_subject()
        name_id = subject.text if subject else None
        name_id_format = subject.format if subject else None

        attributes = self.converter.to_internal(
            self.attribute_profile, response.ava,
        )

        internal_resp = InternalData(
            auth_info=auth_info,
            attributes=attributes,
            subject_type=name_id_format,
            subject_id=name_id,
        )

        satosa_logging(logger, logging.DEBUG,
                       "backend received attributes:\n%s" %
                       json.dumps(response.ava, indent=4), state)
        return internal_resp

    def _metadata_endpoint(self, context):
        """
        Endpoint for retrieving the backend metadata
        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response with metadata
        """
        satosa_logging(logger, logging.DEBUG, "Sending metadata response", context.state)

        metadata_string = create_metadata_string(None, self.sp.config, 4, None, None, None, None,
                                                 None).decode("utf-8")
        return Response(metadata_string, content="text/xml")

    def register_endpoints(self):
        """
        See super class method satosa.backends.base.BackendModule#register_endpoints
        :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))]
        """
        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            parsed_endp = urlparse(endp)
            url_map.append(("^%s$" % parsed_endp.path[1:], functools.partial(self.authn_response, binding=binding)))

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                parsed_endp = urlparse(endp)
                url_map.append(
                    ("^%s$" % parsed_endp.path[1:], self.disco_response))

        if self.expose_entityid_endpoint():
            parsed_entity_id = urlparse(self.sp.config.entityid)
            url_map.append(("^{0}".format(parsed_entity_id.path[1:]),
                            self._metadata_endpoint))

        return url_map

    def get_metadata_desc(self):
        """
        See super class satosa.backends.backend_base.BackendModule#get_metadata_desc
        :rtype: satosa.metadata_creation.description.MetadataDescription
        """
        entity_descriptions = []

        idp_entities = self.sp.metadata.with_descriptor("idpsso")
        for entity_id, entity in idp_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.sp.metadata.extension(entity_id, "idpsso_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
Exemple #11
0
class SamlSP(service.Service):
    def __init__(self, environ, start_response, config, cache=None,
                 outgoing=None, discosrv=None, bindings=None):
        service.Service.__init__(self, environ, start_response)
        self.sp = Base(config, state_cache=cache)
        self.environ = environ
        self.start_response = start_response
        self.cache = cache
        self.idp_disco_query_param = "entityID"
        self.outgoing = outgoing
        self.discosrv = discosrv
        if bindings:
            self.bindings = bindings
        else:
            self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        logger.debug("--- SSO ---")

    def disco_response(self, *args):
        """
        If I got a useful response from the discovery server, continue with
        the authentication request.

        :return: redirect containing the authentication request
        """
        info = self.unpack_redirect()

        try:
            entity_id = info[self.idp_disco_query_param]
        except KeyError:
            resp = Unauthorized("You must chose an IdP")
            return resp(self.environ, self.start_response)
        else:
            # TODO should I check the state variable ?
            return self.authn_request(entity_id, info["state"])

    def store_state(self, authn_req, relay_state, req_args):
        # Which page was accessed to get here.
        came_from = geturl(self.environ)
        key = str(hash(came_from + self.environ["REMOTE_ADDR"] + str(time.time())))
        logger.debug("[sp.challenge] RelayState >> '%s'" % came_from)
        self.cache[key] = (authn_req, relay_state, req_args)
        return key

    def disco_query(self, authn_req, relay_state, req_args):
        """
        This service is expected to always use a discovery service. This is
        where the response is handled

        :param authn_req: The Authentication Request
        :return: A 302 messages redirecting to the discovery service
        """

        state_key = self.store_state(authn_req, relay_state, req_args)

        _cli = self.sp

        eid = _cli.config.entityid
        # Returns list of 2-tuples.
        dr = _cli.config.getattr("endpoints", "sp")["discovery_response"]
        # The first value of the first tuple is the one I want.
        ret = dr[0][0]
        # Append it to the disco server URL.
        ret += "?state=%s" % state_key
        loc = _cli.create_discovery_service_request(self.discosrv, eid,
                                                    **{"return": ret})

        resp = SeeOther(loc)
        return resp(self.environ, self.start_response)

    def authn_request(self, entity_id, state_key):
        _cli = self.sp
        req_args = self.cache[state_key][2]

        try:
            # Picks a binding to use for sending the Request to the IDP.
            _binding, destination = _cli.pick_binding(
                "single_sign_on_service", self.bindings, "idpsso",
                entity_id=entity_id)
            logger.debug("binding: %s, destination: %s" % (_binding,
                                                           destination))
            # Binding here is the response binding that is which binding the
            # IDP should use to return the response.
            acs = _cli.config.getattr("endpoints", "sp")[
                "assertion_consumer_service"]
            # Just pick one.
            endp, return_binding = acs[0]
            req_id, req = _cli.create_authn_request(destination,
                                                    binding=return_binding,
                                                    **req_args)

            ht_args = _cli.apply_binding(_binding, "%s" % req, destination,
                                         relay_state=state_key)
            _sid = req_id
            logger.debug("ht_args: %s" % ht_args)
        except Exception as exc:
            logger.exception(exc)
            resp = ServiceError(
                "Failed to construct the AuthnRequest: %s" % exc)
            return resp(self.environ, self.start_response)

        # Remember the request.
        self.cache[_sid] = state_key
        resp = self.response(_binding, ht_args, do_not_start_response=True)
        return resp(self.environ, self.start_response)

    def authn_response(self, binding):
        """
        :param binding: Which binding the query came in over
        :returns: Error response or a response constructed by the transfer
            function
        """

        _authn_response = self.unpack(binding)

        if not _authn_response["SAMLResponse"]:
            logger.info("Missing Response")
            resp = Unauthorized('Unknown user')
            return resp(self.environ, self.start_response)

        binding = service.INV_BINDING_MAP[binding]
        try:
            _response = self.sp.parse_authn_request_response(
                _authn_response["SAMLResponse"], binding,
                self.cache)
        except UnknownPrincipal as excp:
            logger.error("UnknownPrincipal: %s" % (excp,))
            resp = ServiceError("UnknownPrincipal: %s" % (excp,))
            return resp(self.environ, self.start_response)
        except UnsupportedBinding as excp:
            logger.error("UnsupportedBinding: %s" % (excp,))
            resp = ServiceError("UnsupportedBinding: %s" % (excp,))
            return resp(self.environ, self.start_response)
        except VerificationError as err:
            resp = ServiceError("Verification error: %s" % (err,))
            return resp(self.environ, self.start_response)
        except Exception as err:
            resp = ServiceError("Other error: %s" % (err,))
            return resp(self.environ, self.start_response)

        return self.outgoing(_response, self)

    def register_endpoints(self):
        """
        Given the configuration, return a set of URL to function mappings.
        """

        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            p = urlparse(endp)
            url_map.append(("^%s?(.*)$" % p.path[1:], ("SP", "authn_response",
                                                       BINDING_MAP[binding])))
            url_map.append(("^%s$" % p.path[1:], ("SP", "authn_response",
                                                  BINDING_MAP[binding])))

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                p = urlparse(endp)
                url_map.append(("^%s$" % p.path[1:], ("SP", "disco_response",
                                                            BINDING_MAP[binding])))

        return url_map
Exemple #12
0
class SAMLBackend(BackendModule):
    """
    A saml2 backend module (acting as a SP).
    """

    def __init__(self, outgoing, internal_attributes, config, base_url, name):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]
        :type base_url: str
        :type name: str

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        :param base_url: base url of the service
        :param name: name of the plugin
        """
        super().__init__(outgoing, internal_attributes, base_url, name)

        sp_config = SPConfig().load(copy.deepcopy(config["sp_config"]), False)
        self.sp = Base(sp_config)

        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = config.get("disco_srv")
        self.encryption_keys = []

        key_file_paths = None
        if 'encryption_keypairs' in self.config['sp_config']: # prioritize explicit encryption keypairs
            key_file_paths = [keypair['key_file'] for keypair in self.config['sp_config']['encryption_keypairs']]
        elif 'key_file' in self.config['sp_config']:
            key_file_paths = [self.config['sp_config']['key_file']]

        if key_file_paths:
            for p in key_file_paths:
                with open(p) as key_file:
                    self.encryption_keys.append(key_file.read())

    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
        """

        # if there is only one IdP in the metadata, bypass the discovery service
        idps = self.sp.metadata.identity_providers()
        if len(idps) == 1 and "mdq" not in self.config["sp_config"]["metadata"]:
            return self.authn_request(context, idps[0])

        try:
            # find mirrored entity id
            entity_id = context.internal_data["mirror.target_entity_id"]
        except KeyError:
            # redirect to discovery server
            return self.disco_query()
        else:
            entity_id = urlsafe_b64decode(entity_id).decode("utf-8")
            return self.authn_request(context, entity_id)

    def disco_query(self):
        """
        Makes a request to the discovery server

        :type context: satosa.context.Context
        :type internal_req: satosa.internal_data.InternalRequest
        :rtype: satosa.response.SeeOther

        :param context: The current context
        :param internal_req: The request
        :return: Response
        """
        return_url = self.sp.config.getattr("endpoints", "sp")["discovery_response"][0][0]
        loc = self.sp.create_discovery_service_request(self.discosrv, self.sp.config.entityid, **{"return": return_url})
        return SeeOther(loc)

    def authn_request(self, context, entity_id):
        """
        Do an authorization request on idp with given entity id.
        This is the start of the authorization.

        :type context: satosa.context.Context
        :type entity_id: str
        :rtype: satosa.response.Response

        :param context: The curretn context
        :param entity_id: Target IDP entity id
        :return: response to the user agent
        """
        try:
            binding, destination = self.sp.pick_binding("single_sign_on_service", self.bindings, "idpsso",
                                                        entity_id=entity_id)
            satosa_logging(logger, logging.DEBUG, "binding: %s, destination: %s" % (binding, destination),
                           context.state)
            acs_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"][0]
            req_id, req = self.sp.create_authn_request(destination, binding=response_binding)
            relay_state = rndstr()
            ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state)
            satosa_logging(logger, logging.DEBUG, "ht_args: %s" % ht_args, context.state)
        except Exception as exc:
            satosa_logging(logger, logging.DEBUG, "Failed to construct the AuthnRequest for state", context.state,
                           exc_info=True)
            raise SATOSAAuthenticationError(context.state, "Failed to construct the AuthnRequest") from exc

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

    def authn_response(self, context, binding):
        """
        Endpoint for the idp response
        :type context: satosa.context,Context
        :type binding: str
        :rtype: satosa.response.Response

        :param context: The current context
        :param binding: The saml binding type
        :return: response
        """
        if not context.request["SAMLResponse"]:
            satosa_logging(logger, logging.DEBUG, "Missing Response for state", context.state)
            raise SATOSAAuthenticationError(context.state, "Missing Response")

        try:
            authn_response = self.sp.parse_authn_request_response(context.request["SAMLResponse"], binding)
        except Exception as err:
            satosa_logging(logger, logging.DEBUG, "Failed to parse authn request for state", context.state,
                           exc_info=True)
            raise SATOSAAuthenticationError(context.state, "Failed to parse authn request") from err

        # check if the relay_state matches the cookie state
        if context.state[self.name]["relay_state"] != context.request["RelayState"]:
            satosa_logging(logger, logging.DEBUG,
                           "State did not match relay state for state", context.state)
            raise SATOSAAuthenticationError(context.state, "State did not match relay state")

        del context.state[self.name]
        return self.auth_callback_func(context, self._translate_response(authn_response, context.state))

    def disco_response(self, context):
        """
        Endpoint for the discovery server response

        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response
        """
        info = context.request
        state = context.state

        try:
            entity_id = info["entityID"]
        except KeyError as err:
            satosa_logging(logger, logging.DEBUG, "No IDP chosen for state", state, exc_info=True)
            raise SATOSAAuthenticationError(state, "No IDP chosen") from err

        return self.authn_request(context, entity_id)

    def _translate_response(self, response, state):
        """
        Translates a saml authorization response to an internal response

        :type response: saml2.response.AuthnResponse
        :rtype: satosa.internal_data.InternalResponse
        :param response: The saml authorization response
        :return: A translated internal response
        """

        # The response may have been encrypted by the IdP so if we have an encryption key, try it
        if self.encryption_keys:
            response.parse_assertion(self.encryption_keys)

        authn_info = response.authn_info()[0]
        auth_class_ref = authn_info[0]
        timestamp = response.assertion.authn_statement[0].authn_instant
        issuer = response.response.issuer.text

        auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer)
        internal_resp = InternalResponse(auth_info=auth_info)

        internal_resp.user_id = response.get_subject().text
        internal_resp.attributes = self.converter.to_internal(self.attribute_profile, response.ava)

        satosa_logging(logger, logging.DEBUG, "received attributes:\n%s" % json.dumps(response.ava, indent=4), state)
        return internal_resp

    def _metadata_endpoint(self, context):
        """
        Endpoint for retrieving the backend metadata
        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response with metadata
        """
        satosa_logging(logger, logging.DEBUG, "Sending metadata response", context.state)

        metadata_string = create_metadata_string(None, self.sp.config, 4, None, None, None, None,
                                                 None).decode("utf-8")
        return Response(metadata_string, content="text/xml")

    def register_endpoints(self):
        """
        See super class method satosa.backends.base.BackendModule#register_endpoints
        :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))]
        """
        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            parsed_endp = urlparse(endp)
            url_map.append(("^%s$" % parsed_endp.path[1:], functools.partial(self.authn_response, binding=binding)))

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                parsed_endp = urlparse(endp)
                url_map.append(
                    ("^%s$" % parsed_endp.path[1:], self.disco_response))

        return url_map

    def get_metadata_desc(self):
        """
        See super class satosa.backends.backend_base.BackendModule#get_metadata_desc
        :rtype: satosa.metadata_creation.description.MetadataDescription
        """
        entity_descriptions = []

        idp_entities = self.sp.metadata.with_descriptor("idpsso")
        for entity_id, entity in idp_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.sp.metadata.extension(entity_id, "idpsso_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
Exemple #13
0
class SamlBackend(BackendModule):
    """
    A saml2 backend module
    """

    def __init__(self, outgoing, internal_attributes, config):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        """
        super(SamlBackend, self).__init__(outgoing, internal_attributes)
        sp_config = SPConfig().load(copy.deepcopy(config["config"]), False)

        self.sp = Base(sp_config)
        self.idp_disco_query_param = "entityID"
        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = None
        self.state_id = config["state_id"]
        try:
            self.discosrv = config["disco_srv"]
        except KeyError:
            pass

    @staticmethod
    def create_name_id_policy(usr_id_hash_type):
        """
        Creates a name id policy

        :type usr_id_hash_type: satosa.internal_data.UserIdHashType
        :rtype: saml2.samlp.NameIDPolicy

        :param usr_id_hash_type: The internal id hash type
        :return: A name id policy
        """
        nameid_format = get_saml_name_id_format(usr_id_hash_type)
        name_id_policy = NameIDPolicy(format=nameid_format)
        return name_id_policy

    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
        """

        # if there is only one IdP in the metadata, bypass the discovery service
        idps = self.sp.metadata.identity_providers()
        if len(idps) == 1:
            return self.authn_request(context, idps[0], internal_req)

        try:
            entity_id = context.internal_data["mirror.target_entity_id"]
            entity_id = urlsafe_b64decode(entity_id).decode("utf-8")
            return self.authn_request(context, entity_id, internal_req)
        except KeyError:
            return self.disco_query(context, internal_req)

    def disco_query(self, context, internal_req):
        """
        Makes a request to the discovery server

        :type context: satosa.context.Context
        :type internal_req: satosa.internal_data.InternalRequest
        :rtype: satosa.response.SeeOther

        :param context: The current context
        :param internal_req: The request
        :return: Response
        """
        state = context.state

        _cli = self.sp

        eid = _cli.config.entityid
        # returns list of 2-tuples
        disco_resp = _cli.config.getattr("endpoints", "sp")["discovery_response"]
        # The first value of the first tuple is the one I want
        ret = disco_resp[0][0]
        loc = _cli.create_discovery_service_request(self.discosrv, eid,
                                                    **{"return": ret})
        return SeeOther(loc)

    def authn_request(self, context, entity_id, internal_req):
        """
        Do an authorization request on idp with given entity id.
        This is the start of the authorization.

        :type context: satosa.context.Context
        :type entity_id: str
        :type internal_req: satosa.internal_data.InternalRequest
        :rtype: satosa.response.Response

        :param context: The curretn context
        :param entity_id: Target IDP entity id
        :param internal_req: The request
        :return: Response
        """
        _cli = self.sp
        hash_type = UserIdHashType.persistent.name
        if "hash_type" in self.config:
            hash_type = self.config["hash_type"]
        req_args = {"name_id_policy": self.create_name_id_policy(hash_type)}

        state = context.state

        try:
            # Picks a binding to use for sending the Request to the IDP
            _binding, destination = _cli.pick_binding(
                "single_sign_on_service", self.bindings, "idpsso",
                entity_id=entity_id)
            satosa_logging(LOGGER, logging.DEBUG,
                           "binding: %s, destination: %s" % (_binding, destination), state)
            # Binding here is the response binding that is which binding the
            # IDP should use to return the response.
            acs = _cli.config.getattr("endpoints", "sp")[
                "assertion_consumer_service"]
            # just pick one
            endp, return_binding = acs[0]
            req_id, req = _cli.create_authn_request(destination,
                                                    binding=return_binding,
                                                    **req_args)
            relay_state = rndstr()
            ht_args = _cli.apply_binding(_binding, "%s" % req, destination, relay_state=relay_state)
            satosa_logging(LOGGER, logging.DEBUG, "ht_args: %s" % ht_args, state)
        except Exception as exc:
            satosa_logging(LOGGER, logging.DEBUG,
                           "Failed to construct the AuthnRequest for state", state, exc_info=True)
            raise SATOSAAuthenticationError(state, "Failed to construct the AuthnRequest") from exc

        state.add(self.state_id, relay_state)

        if _binding == BINDING_HTTP_REDIRECT:
            for param, value in ht_args["headers"]:
                if param == "Location":
                    resp = SeeOther(str(value))
                    break
            else:
                satosa_logging(LOGGER, logging.DEBUG, "Parameter error for state", state)
                raise SATOSAAuthenticationError(state, "Parameter error")
        else:
            resp = Response(ht_args["data"], headers=ht_args["headers"])

        return resp

    def authn_response(self, context, binding):
        """
        Endpoint for the idp response
        :type context: satosa.context,Context
        :type binding: str
        :rtype: satosa.response.Response

        :param context: The current context
        :param binding: The saml binding type
        :return: response
        """
        _authn_response = context.request

        state = context.state

        if not _authn_response["SAMLResponse"]:
            satosa_logging(LOGGER, logging.DEBUG, "Missing Response for state", state)
            raise SATOSAAuthenticationError(state, "Missing Response")

        try:
            _response = self.sp.parse_authn_request_response(
                _authn_response["SAMLResponse"], binding)
        except Exception as err:
            satosa_logging(LOGGER, logging.DEBUG,
                           "Failed to parse authn request for state", state,
                           exc_info=True)
            raise SATOSAAuthenticationError(state, "Failed to parse authn request") from err

        # check if the relay_state matches the cookie state
        if state.get(self.state_id) != _authn_response['RelayState']:
            satosa_logging(LOGGER, logging.DEBUG,
                           "State did not match relay state for state", state)
            raise SATOSAAuthenticationError(state, "State did not match relay state")

        context.state.remove(self.state_id)
        return self.auth_callback_func(context, self._translate_response(_response, context.state))

    def disco_response(self, context):
        """
        Endpoint for the discovery server response

        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response
        """
        info = context.request

        state = context.state

        try:
            entity_id = info[self.idp_disco_query_param]
        except KeyError as err:
            satosa_logging(LOGGER, logging.DEBUG, "No IDP chosen for state", state, exc_info=True)
            raise SATOSAAuthenticationError(state, "No IDP chosen") from err
        else:
            request_info = InternalRequest(None, None)
            return self.authn_request(context, entity_id, request_info)

    def _translate_response(self, response, state):
        """
        Translates a saml authorization response to an internal response

        :type response: saml2.response.AuthnResponse
        :rtype: satosa.internal_data.InternalResponse
        :param response: The saml authorization response
        :return: A translated internal response
        """
        _authn_info = response.authn_info()[0]
        timestamp = response.assertion.authn_statement[0].authn_instant
        issuer = response.response.issuer.text
        auth_class_ref = _authn_info[0]

        auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer)
        internal_resp = InternalResponse(auth_info=auth_info)

        internal_resp.set_user_id(response.get_subject().text)
        if "user_id_params" in self.config:
            user_id = ""
            for param in self.config["user_id_params"]:
                try:
                    user_id += response.ava[param]
                except Exception as error:
                    raise SATOSAAuthenticationError from error
            internal_resp.set_user_id(user_id)

        internal_resp.add_attributes(self.converter.to_internal(self.attribute_profile, response.ava))

        satosa_logging(LOGGER, logging.DEBUG,
                       "received attributes:\n%s" % json.dumps(response.ava, indent=4), state)

        return internal_resp

    def _metadata(self, context):
        """
        Endpoint for retrieving the backend metadata
        :type context: satosa.context.Context
        :rtype: satosa.backends.saml2.MetadataResponse

        :param context: The current context
        :return: response with metadata
        """
        satosa_logging(LOGGER, logging.DEBUG, "Sending metadata response", context.state)
        return MetadataResponse(self.sp.config)

    def register_endpoints(self):
        """
        See super class method satosa.backends.base.BackendModule#register_endpoints
        :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))]
        """
        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            parsed_endp = urlparse(endp)
            url_map.append(
                ("^%s?(.*)$" % parsed_endp.path[1:], (self.authn_response, binding)))
            url_map.append(
                ("^%s$" % parsed_endp.path[1:], (self.authn_response, binding)))

        if "publish_metadata" in self.config:
            metadata_path = urlparse(self.config["publish_metadata"])
            url_map.append(("^%s$" % metadata_path.path[1:], self._metadata))

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                parsed_endp = urlparse(endp)
                url_map.append(
                    ("^%s$" % parsed_endp.path[1:], self.disco_response))

        return url_map

    def get_metadata_desc(self):
        """
        See super class vopaas.backends.backend_base.VOPaaSBackendModule#get_metadata_desc
        :rtype: satosa.metadata_creation.description.MetadataDescription
        """
        # TODO Only get IDPs
        metadata_desc = []
        for metadata_file in self.sp.metadata.metadata:
            metadata_file = self.sp.metadata.metadata[metadata_file]
            entity_ids = []

            if metadata_file.entity_descr is None:
                for entity_descr in metadata_file.entities_descr.entity_descriptor:
                    entity_ids.append(entity_descr.entity_id)
            else:
                entity_ids.append(metadata_file.entity_descr.entity_id)

            entity = metadata_file.entity
            for entity_id in entity_ids:

                description = MetadataDescription(
                    urlsafe_b64encode(entity_id.encode("utf-8")).decode("utf-8"))

                # Add organization info
                try:
                    organization = OrganizationDesc()
                    organization_info = entity[entity_id]['organization']

                    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.set_organization(organization)
                except:
                    pass

                # Add contact person info
                try:
                    contact_persons = entity[entity_id]['contact_person']
                    for cont_pers in contact_persons:
                        person = ContactPersonDesc()

                        if 'contact_type' in cont_pers:
                            person.contact_type = cont_pers['contact_type']
                        for address in cont_pers.get('email_address', []):
                            person.add_email_address(address["text"])
                        if 'given_name' in cont_pers:
                            person.given_name = cont_pers['given_name']['text']
                        if 'sur_name' in cont_pers:
                            person.sur_name = cont_pers['sur_name']['text']

                        description.add_contact_person(person)
                except KeyError:
                    pass

                # Add ui info
                try:
                    for idpsso_desc in entity[entity_id]["idpsso_descriptor"]:
                        # TODO Can have more than one ui info?
                        ui_elements = idpsso_desc["extensions"]["extension_elements"]
                        ui_info = UIInfoDesc()

                        for element in ui_elements:
                            if not element["__class__"] == "%s&UIInfo" % UI_NAMESPACE:
                                continue
                            for desc in element.get("description", []):
                                ui_info.add_description(desc["text"], desc["lang"])
                            for name in element.get("display_name", []):
                                ui_info.add_display_name(name["text"], name["lang"])
                            for logo in element.get("logo", []):
                                ui_info.add_logo(logo["text"], logo["width"], logo["height"],
                                                 logo["lang"])

                        description.set_ui_info(ui_info)
                except KeyError:
                    pass

                metadata_desc.append(description)
        return metadata_desc
Exemple #14
0
class SamlSP(service.Service):
    def __init__(self,
                 environ,
                 start_response,
                 config,
                 cache=None,
                 outgoing=None,
                 discosrv=None,
                 bindings=None):
        service.Service.__init__(self, environ, start_response)
        self.sp = Base(config, state_cache=cache)
        self.environ = environ
        self.start_response = start_response
        self.cache = cache
        self.idp_disco_query_param = "entityID"
        self.outgoing = outgoing
        self.discosrv = discosrv
        if bindings:
            self.bindings = bindings
        else:
            self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        logger.debug("--- SSO ---")

    def disco_response(self, *args):
        """
        If I got a useful response from the discovery server, continue with
        the authentication request.

        :return: redirect containing the authentication request
        """
        info = self.unpack_redirect()

        try:
            entity_id = info[self.idp_disco_query_param]
        except KeyError:
            resp = Unauthorized("You must chose an IdP")
            return resp(self.environ, self.start_response)
        else:
            # TODO should I check the state variable ?
            return self.authn_request(entity_id, info["state"])

    def store_state(self, authn_req, relay_state, req_args):
        # Which page was accessed to get here.
        came_from = geturl(self.environ)
        key = str(
            hash(came_from + self.environ["REMOTE_ADDR"] + str(time.time())))
        logger.debug("[sp.challenge] RelayState >> '%s'" % came_from)
        self.cache[key] = (authn_req, relay_state, req_args)
        return key

    def disco_query(self, authn_req, relay_state, req_args):
        """
        This service is expected to always use a discovery service. This is
        where the response is handled

        :param authn_req: The Authentication Request
        :return: A 302 messages redirecting to the discovery service
        """

        state_key = self.store_state(authn_req, relay_state, req_args)

        _cli = self.sp

        eid = _cli.config.entityid
        # Returns list of 2-tuples.
        dr = _cli.config.getattr("endpoints", "sp")["discovery_response"]
        # The first value of the first tuple is the one I want.
        ret = dr[0][0]
        # Append it to the disco server URL.
        ret += "?state=%s" % state_key
        loc = _cli.create_discovery_service_request(self.discosrv, eid,
                                                    **{"return": ret})

        resp = SeeOther(loc)
        return resp(self.environ, self.start_response)

    def authn_request(self, entity_id, state_key):
        _cli = self.sp
        req_args = self.cache[state_key][2]

        try:
            # Picks a binding to use for sending the Request to the IDP.
            _binding, destination = _cli.pick_binding("single_sign_on_service",
                                                      self.bindings,
                                                      "idpsso",
                                                      entity_id=entity_id)
            logger.debug("binding: %s, destination: %s" %
                         (_binding, destination))
            # Binding here is the response binding that is which binding the
            # IDP should use to return the response.
            acs = _cli.config.getattr("endpoints",
                                      "sp")["assertion_consumer_service"]
            # Just pick one.
            endp, return_binding = acs[0]
            req_id, req = _cli.create_authn_request(destination,
                                                    binding=return_binding,
                                                    **req_args)

            ht_args = _cli.apply_binding(_binding,
                                         "%s" % req,
                                         destination,
                                         relay_state=state_key)
            _sid = req_id
            logger.debug("ht_args: %s" % ht_args)
        except Exception as exc:
            logger.exception(exc)
            resp = ServiceError("Failed to construct the AuthnRequest: %s" %
                                exc)
            return resp(self.environ, self.start_response)

        # Remember the request.
        self.cache[_sid] = state_key
        resp = self.response(_binding, ht_args, do_not_start_response=True)
        return resp(self.environ, self.start_response)

    def authn_response(self, binding):
        """
        :param binding: Which binding the query came in over
        :returns: Error response or a response constructed by the transfer
            function
        """

        _authn_response = self.unpack(binding)

        if not _authn_response["SAMLResponse"]:
            logger.info("Missing Response")
            resp = Unauthorized('Unknown user')
            return resp(self.environ, self.start_response)

        binding = service.INV_BINDING_MAP[binding]
        try:
            _response = self.sp.parse_authn_request_response(
                _authn_response["SAMLResponse"], binding, self.cache)
        except UnknownPrincipal as excp:
            logger.error("UnknownPrincipal: %s" % (excp, ))
            resp = ServiceError("UnknownPrincipal: %s" % (excp, ))
            return resp(self.environ, self.start_response)
        except UnsupportedBinding as excp:
            logger.error("UnsupportedBinding: %s" % (excp, ))
            resp = ServiceError("UnsupportedBinding: %s" % (excp, ))
            return resp(self.environ, self.start_response)
        except VerificationError as err:
            resp = ServiceError("Verification error: %s" % (err, ))
            return resp(self.environ, self.start_response)
        except Exception as err:
            resp = ServiceError("Other error: %s" % (err, ))
            return resp(self.environ, self.start_response)

        return self.outgoing(_response, self)

    def register_endpoints(self):
        """
        Given the configuration, return a set of URL to function mappings.
        """

        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            p = urlparse(endp)
            url_map.append(("^%s?(.*)$" % p.path[1:], ("SP", "authn_response",
                                                       BINDING_MAP[binding])))
            url_map.append(("^%s$" % p.path[1:], ("SP", "authn_response",
                                                  BINDING_MAP[binding])))

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                p = urlparse(endp)
                url_map.append(("^%s$" % p.path[1:], ("SP", "disco_response",
                                                      BINDING_MAP[binding])))

        return url_map
Exemple #15
0
class SamlBackend(BackendModule):
    """
    A saml2 backend module
    """
    def __init__(self, outgoing, internal_attributes, config):
        """
        :type outgoing:
        (satosa.context.Context, satosa.internal_data.InternalResponse) -> satosa.response.Response
        :type internal_attributes: dict[str, dict[str, list[str] | str]]
        :type config: dict[str, Any]

        :param outgoing: Callback should be called by the module after
                                   the authorization in the backend is done.
        :param internal_attributes: Internal attribute map
        :param config: The module config
        """
        super(SamlBackend, self).__init__(outgoing, internal_attributes)
        sp_config = SPConfig().load(copy.deepcopy(config["config"]), False)

        self.sp = Base(sp_config)
        self.idp_disco_query_param = "entityID"
        self.config = config
        self.attribute_profile = config.get("attribute_profile", "saml")
        self.bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]
        self.discosrv = None
        self.state_id = config["state_id"]
        try:
            self.discosrv = config["disco_srv"]
        except KeyError:
            pass

    @staticmethod
    def create_name_id_policy(usr_id_hash_type):
        """
        Creates a name id policy

        :type usr_id_hash_type: satosa.internal_data.UserIdHashType
        :rtype: saml2.samlp.NameIDPolicy

        :param usr_id_hash_type: The internal id hash type
        :return: A name id policy
        """
        nameid_format = get_saml_name_id_format(usr_id_hash_type)
        name_id_policy = NameIDPolicy(format=nameid_format)
        return name_id_policy

    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
        """

        # if there is only one IdP in the metadata, bypass the discovery service
        idps = self.sp.metadata.identity_providers()
        if len(idps) == 1:
            return self.authn_request(context, idps[0], internal_req)

        try:
            entity_id = context.internal_data["mirror.target_entity_id"]
            entity_id = urlsafe_b64decode(entity_id).decode("utf-8")
            return self.authn_request(context, entity_id, internal_req)
        except KeyError:
            return self.disco_query(context, internal_req)

    def disco_query(self, context, internal_req):
        """
        Makes a request to the discovery server

        :type context: satosa.context.Context
        :type internal_req: satosa.internal_data.InternalRequest
        :rtype: satosa.response.SeeOther

        :param context: The current context
        :param internal_req: The request
        :return: Response
        """
        state = context.state

        _cli = self.sp

        eid = _cli.config.entityid
        # returns list of 2-tuples
        disco_resp = _cli.config.getattr("endpoints",
                                         "sp")["discovery_response"]
        # The first value of the first tuple is the one I want
        ret = disco_resp[0][0]
        loc = _cli.create_discovery_service_request(self.discosrv, eid,
                                                    **{"return": ret})
        return SeeOther(loc)

    def authn_request(self, context, entity_id, internal_req):
        """
        Do an authorization request on idp with given entity id.
        This is the start of the authorization.

        :type context: satosa.context.Context
        :type entity_id: str
        :type internal_req: satosa.internal_data.InternalRequest
        :rtype: satosa.response.Response

        :param context: The curretn context
        :param entity_id: Target IDP entity id
        :param internal_req: The request
        :return: Response
        """
        _cli = self.sp
        hash_type = UserIdHashType.persistent.name
        if "hash_type" in self.config:
            hash_type = self.config["hash_type"]
        req_args = {"name_id_policy": self.create_name_id_policy(hash_type)}

        state = context.state

        try:
            # Picks a binding to use for sending the Request to the IDP
            _binding, destination = _cli.pick_binding("single_sign_on_service",
                                                      self.bindings,
                                                      "idpsso",
                                                      entity_id=entity_id)
            satosa_logging(
                LOGGER, logging.DEBUG,
                "binding: %s, destination: %s" % (_binding, destination),
                state)
            # Binding here is the response binding that is which binding the
            # IDP should use to return the response.
            acs = _cli.config.getattr("endpoints",
                                      "sp")["assertion_consumer_service"]
            # just pick one
            endp, return_binding = acs[0]
            req_id, req = _cli.create_authn_request(destination,
                                                    binding=return_binding,
                                                    **req_args)
            relay_state = rndstr()
            ht_args = _cli.apply_binding(_binding,
                                         "%s" % req,
                                         destination,
                                         relay_state=relay_state)
            satosa_logging(LOGGER, logging.DEBUG, "ht_args: %s" % ht_args,
                           state)
        except Exception as exc:
            satosa_logging(LOGGER,
                           logging.DEBUG,
                           "Failed to construct the AuthnRequest for state",
                           state,
                           exc_info=True)
            raise SATOSAAuthenticationError(
                state, "Failed to construct the AuthnRequest") from exc

        state.add(self.state_id, relay_state)

        if _binding == BINDING_HTTP_REDIRECT:
            for param, value in ht_args["headers"]:
                if param == "Location":
                    resp = SeeOther(str(value))
                    break
            else:
                satosa_logging(LOGGER, logging.DEBUG,
                               "Parameter error for state", state)
                raise SATOSAAuthenticationError(state, "Parameter error")
        else:
            resp = Response(ht_args["data"], headers=ht_args["headers"])

        return resp

    def authn_response(self, context, binding):
        """
        Endpoint for the idp response
        :type context: satosa.context,Context
        :type binding: str
        :rtype: satosa.response.Response

        :param context: The current context
        :param binding: The saml binding type
        :return: response
        """
        _authn_response = context.request

        state = context.state

        if not _authn_response["SAMLResponse"]:
            satosa_logging(LOGGER, logging.DEBUG, "Missing Response for state",
                           state)
            raise SATOSAAuthenticationError(state, "Missing Response")

        try:
            _response = self.sp.parse_authn_request_response(
                _authn_response["SAMLResponse"], binding)
        except Exception as err:
            satosa_logging(LOGGER,
                           logging.DEBUG,
                           "Failed to parse authn request for state",
                           state,
                           exc_info=True)
            raise SATOSAAuthenticationError(
                state, "Failed to parse authn request") from err

        # check if the relay_state matches the cookie state
        if state.get(self.state_id) != _authn_response['RelayState']:
            satosa_logging(LOGGER, logging.DEBUG,
                           "State did not match relay state for state", state)
            raise SATOSAAuthenticationError(state,
                                            "State did not match relay state")

        context.state.remove(self.state_id)
        return self.auth_callback_func(
            context, self._translate_response(_response, context.state))

    def disco_response(self, context):
        """
        Endpoint for the discovery server response

        :type context: satosa.context.Context
        :rtype: satosa.response.Response

        :param context: The current context
        :return: response
        """
        info = context.request

        state = context.state

        try:
            entity_id = info[self.idp_disco_query_param]
        except KeyError as err:
            satosa_logging(LOGGER,
                           logging.DEBUG,
                           "No IDP chosen for state",
                           state,
                           exc_info=True)
            raise SATOSAAuthenticationError(state, "No IDP chosen") from err
        else:
            request_info = InternalRequest(None, None)
            return self.authn_request(context, entity_id, request_info)

    def _translate_response(self, response, state):
        """
        Translates a saml authorization response to an internal response

        :type response: saml2.response.AuthnResponse
        :rtype: satosa.internal_data.InternalResponse
        :param response: The saml authorization response
        :return: A translated internal response
        """
        _authn_info = response.authn_info()[0]
        timestamp = response.assertion.authn_statement[0].authn_instant
        issuer = response.response.issuer.text
        auth_class_ref = _authn_info[0]

        auth_info = AuthenticationInformation(auth_class_ref, timestamp,
                                              issuer)
        internal_resp = InternalResponse(auth_info=auth_info)

        internal_resp.set_user_id(response.get_subject().text)
        if "user_id_params" in self.config:
            user_id = ""
            for param in self.config["user_id_params"]:
                try:
                    user_id += response.ava[param]
                except Exception as error:
                    raise SATOSAAuthenticationError from error
            internal_resp.set_user_id(user_id)

        internal_resp.add_attributes(
            self.converter.to_internal(self.attribute_profile, response.ava))

        satosa_logging(
            LOGGER, logging.DEBUG,
            "received attributes:\n%s" % json.dumps(response.ava, indent=4),
            state)

        return internal_resp

    def _metadata(self, context):
        """
        Endpoint for retrieving the backend metadata
        :type context: satosa.context.Context
        :rtype: satosa.backends.saml2.MetadataResponse

        :param context: The current context
        :return: response with metadata
        """
        satosa_logging(LOGGER, logging.DEBUG, "Sending metadata response",
                       context.state)
        return MetadataResponse(self.sp.config)

    def register_endpoints(self):
        """
        See super class method satosa.backends.base.BackendModule#register_endpoints
        :rtype list[(str, ((satosa.context.Context, Any) -> Any, Any))]
        """
        url_map = []
        sp_endpoints = self.sp.config.getattr("endpoints", "sp")
        for endp, binding in sp_endpoints["assertion_consumer_service"]:
            parsed_endp = urlparse(endp)
            url_map.append(("^%s?(.*)$" % parsed_endp.path[1:],
                            (self.authn_response, binding)))
            url_map.append(("^%s$" % parsed_endp.path[1:],
                            (self.authn_response, binding)))

        if "publish_metadata" in self.config:
            metadata_path = urlparse(self.config["publish_metadata"])
            url_map.append(("^%s$" % metadata_path.path[1:], self._metadata))

        if self.discosrv:
            for endp, binding in sp_endpoints["discovery_response"]:
                parsed_endp = urlparse(endp)
                url_map.append(
                    ("^%s$" % parsed_endp.path[1:], self.disco_response))

        return url_map

    def get_metadata_desc(self):
        """
        See super class vopaas.backends.backend_base.VOPaaSBackendModule#get_metadata_desc
        :rtype: satosa.metadata_creation.description.MetadataDescription
        """
        # TODO Only get IDPs
        metadata_desc = []
        for metadata_file in self.sp.metadata.metadata:
            metadata_file = self.sp.metadata.metadata[metadata_file]
            entity_ids = []

            if metadata_file.entity_descr is None:
                for entity_descr in metadata_file.entities_descr.entity_descriptor:
                    entity_ids.append(entity_descr.entity_id)
            else:
                entity_ids.append(metadata_file.entity_descr.entity_id)

            entity = metadata_file.entity
            for entity_id in entity_ids:

                description = MetadataDescription(
                    urlsafe_b64encode(
                        entity_id.encode("utf-8")).decode("utf-8"))

                # Add organization info
                try:
                    organization = OrganizationDesc()
                    organization_info = entity[entity_id]['organization']

                    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.set_organization(organization)
                except:
                    pass

                # Add contact person info
                try:
                    contact_persons = entity[entity_id]['contact_person']
                    for cont_pers in contact_persons:
                        person = ContactPersonDesc()

                        if 'contact_type' in cont_pers:
                            person.contact_type = cont_pers['contact_type']
                        for address in cont_pers.get('email_address', []):
                            person.add_email_address(address["text"])
                        if 'given_name' in cont_pers:
                            person.given_name = cont_pers['given_name']['text']
                        if 'sur_name' in cont_pers:
                            person.sur_name = cont_pers['sur_name']['text']

                        description.add_contact_person(person)
                except KeyError:
                    pass

                # Add ui info
                try:
                    for idpsso_desc in entity[entity_id]["idpsso_descriptor"]:
                        # TODO Can have more than one ui info?
                        ui_elements = idpsso_desc["extensions"][
                            "extension_elements"]
                        ui_info = UIInfoDesc()

                        for element in ui_elements:
                            if not element[
                                    "__class__"] == "%s&UIInfo" % UI_NAMESPACE:
                                continue
                            for desc in element.get("description", []):
                                ui_info.add_description(
                                    desc["text"], desc["lang"])
                            for name in element.get("display_name", []):
                                ui_info.add_display_name(
                                    name["text"], name["lang"])
                            for logo in element.get("logo", []):
                                ui_info.add_logo(logo["text"], logo["width"],
                                                 logo["height"], logo["lang"])

                        description.set_ui_info(ui_info)
                except KeyError:
                    pass

                metadata_desc.append(description)
        return metadata_desc