def test_1(): c = SPConfig().load(sp1) c.context = "sp" print(c) assert c._sp_endpoints assert c._sp_name assert c._sp_idp md = c.metadata assert isinstance(md, MetadataStore) assert len(c._sp_idp) == 1 assert list(c._sp_idp.keys()) == ["urn:mace:example.com:saml:roland:idp"] assert list(c._sp_idp.values()) == [{ 'single_sign_on_service': { 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect': ('http://localhost:8088/sso/') } }] assert c.only_use_keys_in_metadata assert type(c.getattr("requested_authn_context")) is dict assert c.getattr("requested_authn_context").get( "authn_context_class_ref") == [ AUTHN_PASSWORD_PROTECTED, AUTHN_TIME_SYNC_TOKEN, ] assert c.getattr("requested_authn_context").get("comparison") == "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) 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_config = SPConfig().load(copy.deepcopy(config[SAMLBackend.KEY_SP_CONFIG])) # if encryption_keypairs is defined, use those keys for decryption # else, if key_file and cert_file are defined, use them for decryption # otherwise, do not use any decryption key. # ensure the choice is reflected back in the configuration. sp_conf_encryption_keypairs = sp_config.getattr('encryption_keypairs', '') sp_conf_key_file = sp_config.getattr('key_file', '') sp_conf_cert_file = sp_config.getattr('cert_file', '') sp_keypairs = ( sp_conf_encryption_keypairs if sp_conf_encryption_keypairs else [{'key_file': sp_conf_key_file, 'cert_file': sp_conf_cert_file}] if sp_conf_key_file and sp_conf_cert_file else [] ) sp_config.setattr('', 'encryption_keypairs', sp_keypairs) # load the encryption keys key_file_paths = [pair['key_file'] for pair in sp_keypairs] for p in key_file_paths: with open(p) as key_file: self.encryption_keys.append(key_file.read()) # finally, initialize the client object self.sp = Saml2Client(sp_config)
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())
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 test_2(): c = SPConfig().load(sp2) c.context = "sp" print c assert c._sp_endpoints assert c.getattr("endpoints", "sp") assert c._sp_idp assert c._sp_optional_attributes assert c.name assert c._sp_required_attributes assert len(c._sp_idp) == 1 assert c._sp_idp.keys() == [""] assert c._sp_idp.values() == ["https://example.com/saml2/idp/SSOService.php"] assert c.only_use_keys_in_metadata is True
def test_2(): c = SPConfig().load(sp2) c.context = "sp" print(c) assert c._sp_endpoints assert c.getattr("endpoints", "sp") assert c._sp_idp assert c._sp_optional_attributes assert c.name assert c._sp_required_attributes assert len(c._sp_idp) == 1 assert list(c._sp_idp.keys()) == [""] assert list(c._sp_idp.values()) == [ "https://example.com/saml2/idp/SSOService.php"] assert c.only_use_keys_in_metadata is True
def test_set_force_authn(): cnf = SPConfig().load(sp2) assert bool(cnf.getattr('force_authn', 'sp')) == True
def test_unset_force_authn(): cnf = SPConfig().load(sp1) assert bool(cnf.getattr('force_authn', 'sp')) == False
class Saml(object): """ SAML Wrapper around pysaml2. Implements SAML2 Service Provider functionality for Flask. """ def __init__(self, config): """Initialize SAML Service Provider. Args: config (dict): Service Provider config info in dict form """ if config.get('metadata') is not None: config['metadata'] = _parse_metadata_dict_to_inline( config['metadata']) self._config = SPConfig().load(config) self._config.setattr('', 'allow_unknown_attributes', True) # Set discovery end point, if configured for. if config['service']['sp'].get('ds'): self.discovery_service_end_point = \ config['service']['sp'].get('ds')[0] def authenticate(self, next_url="/", binding=BINDING_HTTP_REDIRECT, selected_idp=None): """Start SAML Authentication login process. Args: next_url (string): HTTP URL to return user to when authentication is complete. binding (binding): Saml2 binding method to use for request. Defaults to BINDING_HTTP_REDIRECT (don't change til HTTP_POST support is complete in pysaml2). selected_idp (string): A specfic IdP that should be used to authenticate. Defaults to `None`. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: when unable to locate valid IdP. BadRequest: when invalid result returned from SAML client. """ # Fail if signing requested but no private key configured. if self._config.getattr('authn_requests_signed') == True: if not self._config.key_file \ or not os.path.exists(self._config.key_file): raise AuthException( "Signature requested for this Saml authentication" + \ " request, but no private key file configured") # Find configured for IdPs for requested binding method. bindable_idps = [] all_idps = self._config.metadata.identity_providers() # Filter IdPs to allowed IdPs, if we have some. if self._config.getattr('idp') is not None: all_idps = list(set(all_idps) & set(self._config.getattr('idp'))) # Filter IdPs to selected IdP, if we have one. if selected_idp is not None: all_idps = list(set(all_idps) & set([selected_idp])) # From all IdPs allowed/selected, get the ones we can bind to. for idp in all_idps: if self._config.metadata.single_sign_on_service(idp, binding) != []: bindable_idps.append(idp) if not len(bindable_idps): raise AuthException("Unable to locate valid IdP for this request") # Retrieve cache. outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') LOGGER.debug("Outstanding queries cache %s", outstanding_queries_cache) if len(bindable_idps) > 1: # Redirect to discovery service (session_id, response) = self._handle_discovery_request() else: idp_entityid = bindable_idps[0] LOGGER.debug("Connecting to Identity Provider %s", idp_entityid) # Make pysaml2 call to authenticate. client = Saml2Client(self._config) (session_id, result) = client.prepare_for_authenticate( entityid=idp_entityid, relay_state=next_url, sign=self._config.getattr('authn_requests_signed'), binding=binding) # The psaml2 source for this method indicates that # BINDING_HTTP_POST should not be used right now to authenticate. # Regardless, we'll check for it and act accordingly. if binding == BINDING_HTTP_REDIRECT: LOGGER.debug("Redirect to Identity Provider %s ( %s )", idp_entityid, result) response = make_response("", 302, dict(result['headers'])) elif binding == BINDING_HTTP_POST: LOGGER.debug("Post to Identity Provider %s ( %s )", idp_entityid, result) response = result, 200 else: raise BadRequest("Invalid result returned from SAML client") LOGGER.debug( "Saving session_id ( %s ) in outstanding queries", session_id) # cache the outstanding query outstanding_queries_cache.update({session_id: next_url}) outstanding_queries_cache.sync() LOGGER.debug("Outstanding queries cache %s", session['_saml_outstanding_queries']) return response def _handle_discovery_request(self): """Handle SAML Discovery Service request. This method is called internally by the `authenticate` method when multiple acceptable IdPs are detected. Returns: Tuple containing session Id and Flask Response object to return to user containing either HTTP_REDIRECT to configured Discovery Service end point. Raises: AuthException: when unable to find discovery response end point. """ session_id = sid() try: return_url = self._config.getattr( 'endpoints', 'sp')['discovery_response'][0][0] except KeyError: raise AuthException( "Multiple IdPs configured with no configured Discovery" + \ " response end point.") return_url += "?session_id=%s" % session_id disco_url = Saml2Client.create_discovery_service_request( self.discovery_service_end_point, self._config.entityid, **{'return': return_url}) LOGGER.debug("Redirect to Discovery Service %s", disco_url) return (session_id, make_response('', 302, {'Location': disco_url})) def handle_discovery_response(self, request): """Handle SAML Discovery Service response. This method is basically a wrapper around `authenticate` with a little extra logic for getting the `entityID` out of the request and the next_url and binding that was previously submitted to `authenticate` from the user's session. Args: request (Request): Flask request object for this HTTP transaction. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: when unable to locate valid IdP. BadRequest: when invalid result returned from SAML client. """ session_id = request.args.get('session_id') next_url = "/" # Retrieve cache. Get `next_url` from cache. outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') if session_id in outstanding_queries_cache.keys(): next_url = outstanding_queries_cache[session_id] del outstanding_queries_cache[session_id] outstanding_queries_cache.sync() # Get the selected IdP from the Discovery Service response. selected_idp = Saml2Client.parse_discovery_service_response( query=request.query_string) return self.authenticate(next_url=next_url, selected_idp=selected_idp) def handle_assertion(self, request): """Handle SAML Authentication login assertion (POST). Args: request (Request): Flask request object for this HTTP transaction. Returns: (tuple) SAML assertion response information (dict) containing the IdP entity id, the subject's name id, and any additional attributes which may have been returned in the assertion, and Redirect Flask response object to return user to now that authentication is complete. Raises: BadRequest: when error with SAML response from Identity Provider. AuthException: when unable to locate uid attribute in response. """ if not request.form.get('SAMLResponse'): raise BadRequest("SAMLResponse missing from POST") # retrieve cache outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') identity_cache = IdentityCache(session, '_saml_identity') LOGGER.debug("Outstanding queries cache %s", outstanding_queries_cache) LOGGER.debug("Identity cache %s", identity_cache) # use pysaml2 to process the SAML authentication response client = Saml2Client(self._config, identity_cache=identity_cache) saml_response = client.parse_authn_request_response( request.form['SAMLResponse'], BINDING_HTTP_POST, outstanding=outstanding_queries_cache) if saml_response is None: raise BadRequest("SAML response is invalid") # make sure outstanding query cache is cleared for this session_id session_id = saml_response.session_id() if session_id in outstanding_queries_cache.keys(): del outstanding_queries_cache[session_id] outstanding_queries_cache.sync() saml_subject_id = saml_response.name_id # Assemble SAML assertion info for returning to the method caller. saml_assertion_info = saml_response.get_identity() # Note: SAML assertion attributes can have multiple values so the # values returned for these attributes are lists even if there is only # one entry. For consistency the `name_id` returned with the SAML # assertion information has been included as a single item list. saml_assertion_info['name_id'] = [saml_response.get_subject().text] # The IdP entity id is obviously not an attribute, so no list required. saml_assertion_info['idp_entity_id'] = saml_response.issuer() LOGGER.debug("SAML Session Info ( %s )", saml_assertion_info) # set subject Id in cache to retrieved name_id session['_saml_subject_id'] = saml_subject_id LOGGER.debug("Outstanding queries cache %s", session['_saml_outstanding_queries']) LOGGER.debug("Identity cache %s", session['_saml_identity']) LOGGER.debug("Subject Id %s", session['_saml_subject_id']) relay_state = request.form.get('RelayState', "/") LOGGER.debug("Returning redirect to %s", relay_state) return (saml_assertion_info, redirect(relay_state)) def logout(self, next_url='/', expire=None): """Start SAML Authentication logout process. Args: next_url (string): HTTP URL to return user to when logout is complete. expire (struct_time): The latest the log out should happen. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: Can not resolve IdP single logout end-point. """ # retrieve cache state_cache = AuthDictCache(session, '_saml_state') identity_cache = IdentityCache(session, '_saml_identity') subject_id = session.get('_saml_subject_id') # don't logout if not logged in if subject_id is None: raise AuthException("Unable to retrieve subject id for logout") # fail if signing requested but no private key configured if self._config.getattr('logout_requests_signed') == True: LOGGER.debug("key_file %s", self._config.key_file) if not self._config.key_file \ or not os.path.exists(self._config.key_file): raise AuthException( "Signature requested for this Saml logout request," + \ " but no private key file configured") LOGGER.debug("State cache %s", state_cache) LOGGER.debug("Identity cache %s", identity_cache) LOGGER.debug("Subject Id %s", subject_id) # use pysaml2 to initiate the SAML logout request client = Saml2Client( self._config, state_cache=state_cache, identity_cache=identity_cache) saml_response = client.global_logout(subject_id, expire=expire) # sync the state to cache state_cache.sync() LOGGER.debug("State cache %s", session['_saml_state']) LOGGER.debug("Identity cache %s", session['_saml_identity']) if saml_response.get('1', None) == "": # used SOAP BINDING successfully return redirect(next_url) LOGGER.debug("Returning Response from SAML for continuation of the" + \ " logout process") for _, item in saml_response.items(): if isinstance(item, tuple): http_type, htargs = item break if http_type == BINDING_HTTP_POST: return htargs, 200 else: return make_response("", 302, htargs['headers']) def handle_logout(self, request, next_url="/"): """Handle SAML Authentication logout request/response. Args: request (Request): Flask request object for this HTTP transaction. next_url (string): URL to get redirected to if all is successful. Returns: (boolean) Success, Flask Response object to return to user containing HTTP_REDIRECT SAML message. Raises: BadRequest: when SAML request/response data is missing. """ # retrieve cache state_cache = AuthDictCache(session, '_saml_state') identity_cache = IdentityCache(session, '_saml_identity') subject_id = session.get('_saml_subject_id') LOGGER.debug("State cache %s", state_cache) LOGGER.debug("Identity cache %s", identity_cache) LOGGER.debug("Subject Id %s", subject_id) # use pysaml2 to complete the SAML logout request client = Saml2Client( self._config, state_cache=state_cache, identity_cache=identity_cache) # let's try to figure out what binding is being used and what type of # logout call we are handling if request.args: binding = BINDING_HTTP_REDIRECT elif request.form: binding = BINDING_HTTP_POST else: # The SOAP binding is only valid on logout requests which currently # pysaml2 doesn't support. raise BadRequest("Unable to find supported binding") if "SAMLRequest" in request.values: response = _handle_logout_request( client, request, subject_id, binding) elif "SAMLResponse" in request.values: response = _handle_logout_response( client, request, binding, next_url) else: raise BadRequest("Unable to find SAMLRequest or SAMLResponse") # cache the state and remove subject if logout was successful or # this subject was already logged out. success = not subject_id or \ identity_cache.get_identity(subject_id) == ({}, []) if success: session.pop('_saml_subject_id', None) state_cache.sync() LOGGER.debug("State cache %s", session['_saml_state']) LOGGER.debug("Identity cache %s", session['_saml_identity']) LOGGER.debug( "Returning redirect to complete/continue the logout process") return success, response def get_metadata(self): """Returns SAML Service Provider Metadata""" edesc = entity_descriptor(self._config) if self._config.key_file: _, edesc = sign_entity_descriptor( edesc, None, security_context(self._config)) response = make_response(str(edesc)) response.headers['Content-type'] = "text/xml; charset=utf-8" return response
class Saml(object): """ SAML Wrapper around pysaml2. Implements SAML2 Service Provider functionality for Flask. """ def __init__(self, config): """Initialize SAML Service Provider. Args: config (dict): Service Provider config info in dict form """ if config.get('metadata') is not None: config['metadata'] = _parse_metadata_dict_to_inline( config['metadata']) self._config = SPConfig().load(config) self._config.setattr('', 'allow_unknown_attributes', True) # Set discovery end point, if configured for. if config['service']['sp'].get('ds'): self.discovery_service_end_point = \ config['service']['sp'].get('ds')[0] def authenticate(self, next_url="/", binding=BINDING_HTTP_REDIRECT, selected_idp=None): """Start SAML Authentication login process. Args: next_url (string): HTTP URL to return user to when authentication is complete. binding (binding): Saml2 binding method to use for request. Defaults to BINDING_HTTP_REDIRECT (don't change til HTTP_POST support is complete in pysaml2). selected_idp (string): A specfic IdP that should be used to authenticate. Defaults to `None`. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: when unable to locate valid IdP. BadRequest: when invalid result returned from SAML client. """ # Fail if signing requested but no private key configured. if self._config.getattr('authn_requests_signed') == True: if not self._config.key_file \ or not os.path.exists(self._config.key_file): raise AuthException( "Signature requested for this Saml authentication" + \ " request, but no private key file configured") # Find configured for IdPs for requested binding method. bindable_idps = [] all_idps = self._config.metadata.identity_providers() # Filter IdPs to allowed IdPs, if we have some. if self._config.getattr('idp') is not None: all_idps = list(set(all_idps) & set(self._config.getattr('idp'))) # Filter IdPs to selected IdP, if we have one. if selected_idp is not None: all_idps = list(set(all_idps) & set([selected_idp])) # From all IdPs allowed/selected, get the ones we can bind to. for idp in all_idps: if self._config.metadata.single_sign_on_service(idp, binding) != []: bindable_idps.append(idp) if not len(bindable_idps): raise AuthException("Unable to locate valid IdP for this request") # Retrieve cache. outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') LOGGER.debug("Outstanding queries cache %s", outstanding_queries_cache) if len(bindable_idps) > 1: # Redirect to discovery service (session_id, response) = self._handle_discovery_request() else: idp_entityid = bindable_idps[0] LOGGER.debug("Connecting to Identity Provider %s", idp_entityid) # Make pysaml2 call to authenticate. client = Saml2Client(self._config) (session_id, result) = client.prepare_for_authenticate( entityid=idp_entityid, relay_state=next_url, sign=self._config.getattr('authn_requests_signed'), binding=binding) # The psaml2 source for this method indicates that # BINDING_HTTP_POST should not be used right now to authenticate. # Regardless, we'll check for it and act accordingly. if binding == BINDING_HTTP_REDIRECT: LOGGER.debug("Redirect to Identity Provider %s ( %s )", idp_entityid, result) response = make_response("", 302, dict(result['headers'])) elif binding == BINDING_HTTP_POST: LOGGER.debug("Post to Identity Provider %s ( %s )", idp_entityid, result) response = result, 200 else: raise BadRequest("Invalid result returned from SAML client") LOGGER.debug("Saving session_id ( %s ) in outstanding queries", session_id) # cache the outstanding query outstanding_queries_cache.update({session_id: next_url}) outstanding_queries_cache.sync() LOGGER.debug("Outstanding queries cache %s", session['_saml_outstanding_queries']) return response def _handle_discovery_request(self): """Handle SAML Discovery Service request. This method is called internally by the `authenticate` method when multiple acceptable IdPs are detected. Returns: Tuple containing session Id and Flask Response object to return to user containing either HTTP_REDIRECT to configured Discovery Service end point. Raises: AuthException: when unable to find discovery response end point. """ session_id = sid() try: return_url = self._config.getattr('endpoints', 'sp')['discovery_response'][0][0] except KeyError: raise AuthException( "Multiple IdPs configured with no configured Discovery" + \ " response end point.") return_url += "?session_id=%s" % session_id disco_url = Saml2Client.create_discovery_service_request( self.discovery_service_end_point, self._config.entityid, **{'return': return_url}) LOGGER.debug("Redirect to Discovery Service %s", disco_url) return (session_id, make_response('', 302, {'Location': disco_url})) def handle_discovery_response(self, request): """Handle SAML Discovery Service response. This method is basically a wrapper around `authenticate` with a little extra logic for getting the `entityID` out of the request and the next_url and binding that was previously submitted to `authenticate` from the user's session. Args: request (Request): Flask request object for this HTTP transaction. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: when unable to locate valid IdP. BadRequest: when invalid result returned from SAML client. """ session_id = request.args.get('session_id') next_url = "/" # Retrieve cache. Get `next_url` from cache. outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') if session_id in outstanding_queries_cache.keys(): next_url = outstanding_queries_cache[session_id] del outstanding_queries_cache[session_id] outstanding_queries_cache.sync() # Get the selected IdP from the Discovery Service response. selected_idp = Saml2Client.parse_discovery_service_response( query=request.query_string) return self.authenticate(next_url=next_url, selected_idp=selected_idp) def handle_assertion(self, request): """Handle SAML Authentication login assertion (POST). Args: request (Request): Flask request object for this HTTP transaction. Returns: (tuple) SAML assertion response information (dict) containing the IdP entity id, the subject's name id, and any additional attributes which may have been returned in the assertion, and Redirect Flask response object to return user to now that authentication is complete. Raises: BadRequest: when error with SAML response from Identity Provider. AuthException: when unable to locate uid attribute in response. """ if not request.form.get('SAMLResponse'): raise BadRequest("SAMLResponse missing from POST") # retrieve cache outstanding_queries_cache = \ AuthDictCache(session, '_saml_outstanding_queries') identity_cache = IdentityCache(session, '_saml_identity') LOGGER.debug("Outstanding queries cache %s", outstanding_queries_cache) LOGGER.debug("Identity cache %s", identity_cache) # use pysaml2 to process the SAML authentication response client = Saml2Client(self._config, identity_cache=identity_cache) saml_response = client.parse_authn_request_response( request.form['SAMLResponse'], BINDING_HTTP_POST, outstanding=outstanding_queries_cache) if saml_response is None: raise BadRequest("SAML response is invalid") # make sure outstanding query cache is cleared for this session_id session_id = saml_response.session_id() if session_id in outstanding_queries_cache.keys(): del outstanding_queries_cache[session_id] outstanding_queries_cache.sync() saml_subject_id = saml_response.name_id # Assemble SAML assertion info for returning to the method caller. saml_assertion_info = saml_response.get_identity() # Note: SAML assertion attributes can have multiple values so the # values returned for these attributes are lists even if there is only # one entry. For consistency the `name_id` returned with the SAML # assertion information has been included as a single item list. saml_assertion_info['name_id'] = [saml_response.get_subject().text] # The IdP entity id is obviously not an attribute, so no list required. saml_assertion_info['idp_entity_id'] = saml_response.issuer() LOGGER.debug("SAML Session Info ( %s )", saml_assertion_info) # set subject Id in cache to retrieved name_id session['_saml_subject_id'] = saml_subject_id LOGGER.debug("Outstanding queries cache %s", session['_saml_outstanding_queries']) LOGGER.debug("Identity cache %s", session['_saml_identity']) LOGGER.debug("Subject Id %s", session['_saml_subject_id']) relay_state = request.form.get('RelayState', "/") LOGGER.debug("Returning redirect to %s", relay_state) return (saml_assertion_info, redirect(relay_state)) def logout(self, next_url='/', expire=None): """Start SAML Authentication logout process. Args: next_url (string): HTTP URL to return user to when logout is complete. expire (struct_time): The latest the log out should happen. Returns: Flask Response object to return to user containing either HTTP_REDIRECT or HTTP_POST SAML message. Raises: AuthException: Can not resolve IdP single logout end-point. """ # retrieve cache state_cache = AuthDictCache(session, '_saml_state') identity_cache = IdentityCache(session, '_saml_identity') subject_id = session.get('_saml_subject_id') # don't logout if not logged in if subject_id is None: raise AuthException("Unable to retrieve subject id for logout") # fail if signing requested but no private key configured if self._config.getattr('logout_requests_signed') == True: LOGGER.debug("key_file %s", self._config.key_file) if not self._config.key_file \ or not os.path.exists(self._config.key_file): raise AuthException( "Signature requested for this Saml logout request," + \ " but no private key file configured") LOGGER.debug("State cache %s", state_cache) LOGGER.debug("Identity cache %s", identity_cache) LOGGER.debug("Subject Id %s", subject_id) # use pysaml2 to initiate the SAML logout request client = Saml2Client(self._config, state_cache=state_cache, identity_cache=identity_cache) saml_response = client.global_logout(subject_id, expire=expire) # sync the state to cache state_cache.sync() LOGGER.debug("State cache %s", session['_saml_state']) LOGGER.debug("Identity cache %s", session['_saml_identity']) if saml_response.get('1', None) == "": # used SOAP BINDING successfully return redirect(next_url) LOGGER.debug("Returning Response from SAML for continuation of the" + \ " logout process") for _, item in saml_response.items(): if isinstance(item, tuple): http_type, htargs = item break if http_type == BINDING_HTTP_POST: return htargs, 200 else: return make_response("", 302, htargs['headers']) def handle_logout(self, request, next_url="/"): """Handle SAML Authentication logout request/response. Args: request (Request): Flask request object for this HTTP transaction. next_url (string): URL to get redirected to if all is successful. Returns: (boolean) Success, Flask Response object to return to user containing HTTP_REDIRECT SAML message. Raises: BadRequest: when SAML request/response data is missing. """ # retrieve cache state_cache = AuthDictCache(session, '_saml_state') identity_cache = IdentityCache(session, '_saml_identity') subject_id = session.get('_saml_subject_id') LOGGER.debug("State cache %s", state_cache) LOGGER.debug("Identity cache %s", identity_cache) LOGGER.debug("Subject Id %s", subject_id) # use pysaml2 to complete the SAML logout request client = Saml2Client(self._config, state_cache=state_cache, identity_cache=identity_cache) # let's try to figure out what binding is being used and what type of # logout call we are handling if request.args: binding = BINDING_HTTP_REDIRECT elif request.form: binding = BINDING_HTTP_POST else: # The SOAP binding is only valid on logout requests which currently # pysaml2 doesn't support. raise BadRequest("Unable to find supported binding") if "SAMLRequest" in request.values: response = _handle_logout_request(client, request, subject_id, binding) elif "SAMLResponse" in request.values: response = _handle_logout_response(client, request, binding, next_url) else: raise BadRequest("Unable to find SAMLRequest or SAMLResponse") # cache the state and remove subject if logout was successful or # this subject was already logged out. success = not subject_id or \ identity_cache.get_identity(subject_id) == ({}, []) if success: session.pop('_saml_subject_id', None) state_cache.sync() LOGGER.debug("State cache %s", session['_saml_state']) LOGGER.debug("Identity cache %s", session['_saml_identity']) LOGGER.debug( "Returning redirect to complete/continue the logout process") return success, response def get_metadata(self): """Returns SAML Service Provider Metadata""" edesc = entity_descriptor(self._config) if self._config.key_file: _, edesc = sign_entity_descriptor(edesc, None, security_context(self._config)) response = make_response(str(edesc)) response.headers['Content-type'] = "text/xml; charset=utf-8" return response