Repository: zulip/zulip

Issue:
SAML Logout
Excuse me if this is not the appropriate site to ask. 
I'm testing Zulip SAML login with Microsoft ADFS. Login is working fine, but I can't find how to set up the Logout Service. Is it implemented?

As a side note shouldn't be the default signing algorithm set to sha-256?

Keep up this great work!


Discussion:
Hello @zulip/server-authentication members, this issue was labeled with the "area: authentication" label, so you may want to check it out!

<!-- areaLabelAddition -->
I don't think we do SAML logout service yet; it's definitely something we want to add soon.

Regarding signing, according to https://python-social-auth-docs.readthedocs.io/en/latest/backends/saml.html, python-social-auth is using the defaults from `python-saml`, which seem to be incorrectly SHA-1: https://github.com/onelogin/python-saml/issues/269.

We probably can just change this ourselves; can you test whether this modified security config in `/etc/zulip/settings.py` correctly does SHA-256?:

```
SOCIAL_AUTH_SAML_SECURITY_CONFIG = 
{
    # If you've set up the optional private and public server keys,                                  
    # set this to True to enable signing of SAMLRequests using the                                   
    # private key.                                                                                   
    "authnRequestsSigned": False,                                                                    
    "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
}
```
> Is it implemented?

Work in progress currently, I got into working on it for a while, but then got distracted with other tasks. I'll try to find time to get back to it soon.

> I'm testing Zulip SAML login with Microsoft ADFS.

Did you have to set ``lowercase_urlencoding`` option to ``True``, or did things work fine without that? I'm asking because ``python3-saml`` doc says:

> lowercase_urlencoding - Defaults to false. ADFS users should set this to true.

Which I bumped into some time ago and it seems like a very annoying fiddly detail to have to figure out to get things working.

> As a side note shouldn't be the default signing algorithm set to sha-256?

Ouch, good catch. We certainly should change that.


 
> We probably can just change this ourselves; can you test whether this modified security config in `/etc/zulip/settings.py` correctly does SHA-256?:
> 
> ```
> SOCIAL_AUTH_SAML_SECURITY_CONFIG = 
> {
>     # If you've set up the optional private and public server keys,                                  
>     # set this to True to enable signing of SAMLRequests using the                                   
>     # private key.                                                                                   
>     "authnRequestsSigned": False,                                                                    
>     "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
> }
> ```

That's exactly what I did to get it to work. And yes, it does.
> lowercase_urlencoding

I did not set that, and authentication it's working so far. But I'll have it in mind.

Hello @mateuszmandera, you have been unassigned from this issue because you have not updated this issue or any referenced pull requests for over 14 days.

You can reclaim this issue or claim any other issue by commenting `@zulipbot claim` on that issue.

Thanks for your contributions, and hope to see you again soon!
Hello,
Is there some news about the SAML logout service ?
@mateuszmandera did you begin something one this?
Thank you.
Hi @gdelafond! Sadly no new progress on this since then. But I've been working on SAML related things in general lately, so it'd make sense for me to pick this back up once I'm done with the SCIM integration #19708 
@mateuszmandera SCIM will be nice to have :)
Is there a way we could help on SAML logout?
@gdelafond certainly!  In what way are you interested in helping?  

Structurally, we can do a little writeup that points out the functions one would want to call to do the plumbing, and then we can help write tests once one has a working prototype, but I'm not sure what you had in mind.

Resolving patch:
diff --git a/zerver/lib/types.py b/zerver/lib/types.py
--- a/zerver/lib/types.py
+++ b/zerver/lib/types.py
@@ -59,6 +59,7 @@ class LinkifierDict(TypedDict):
 class SAMLIdPConfigDict(TypedDict, total=False):
     entity_id: str
     url: str
+    slo_url: str
     attr_user_permanent_id: str
     attr_first_name: str
     attr_last_name: str
diff --git a/zproject/backends.py b/zproject/backends.py
--- a/zproject/backends.py
+++ b/zproject/backends.py
@@ -35,6 +35,7 @@
 from django_auth_ldap.backend import LDAPBackend, _LDAPUser, ldap_error
 from lxml.etree import XMLSyntaxError
 from onelogin.saml2.errors import OneLogin_Saml2_Error
+from onelogin.saml2.logout_request import OneLogin_Saml2_Logout_Request
 from onelogin.saml2.response import OneLogin_Saml2_Response
 from onelogin.saml2.settings import OneLogin_Saml2_Settings
 from requests import HTTPError
@@ -62,6 +63,7 @@
     do_create_user,
     do_deactivate_user,
     do_reactivate_user,
+    do_regenerate_api_key,
     do_update_user_custom_profile_data_if_changed,
 )
 from zerver.lib.avatar import avatar_url, is_avatar_new
@@ -73,6 +75,7 @@
 from zerver.lib.rate_limiter import RateLimitedObject
 from zerver.lib.redis_utils import get_dict_from_redis, get_redis_client, put_dict_in_redis
 from zerver.lib.request import RequestNotes
+from zerver.lib.sessions import delete_user_sessions
 from zerver.lib.subdomains import get_subdomain
 from zerver.lib.types import ProfileDataElementValue
 from zerver.lib.url_encoding import append_url_query_string
@@ -2252,21 +2255,33 @@ def get_data_from_redis(cls, key: str) -> Optional[Dict[str, Any]]:
 
         return data
 
-    def get_issuing_idp(self, SAMLResponse: str) -> Optional[str]:
+    def get_issuing_idp(self, saml_response_or_request: Tuple[str, str]) -> Optional[str]:
         """
-        Given a SAMLResponse, returns which of the configured IdPs is declared as the issuer.
+        Given a SAMLResponse or SAMLRequest, returns which of the configured IdPs
+        is declared as the issuer.
         This value MUST NOT be trusted as the true issuer!
         The signatures are not validated, so it can be tampered with by the user.
         That's not a problem for this function,
         and true validation happens later in the underlying libraries, but it's important
         to note this detail. The purpose of this function is merely as a helper to figure out which
-        of the configured IdPs' information to use for parsing and validating the response.
+        of the configured IdPs' information to use for parsing and validating the request.
         """
         try:
             config = self.generate_saml_config()
             saml_settings = OneLogin_Saml2_Settings(config, sp_validation_only=True)
-            resp = OneLogin_Saml2_Response(settings=saml_settings, response=SAMLResponse)
-            issuers = resp.get_issuers()
+            if saml_response_or_request[1] == "SAMLResponse":
+                resp = OneLogin_Saml2_Response(
+                    settings=saml_settings, response=saml_response_or_request[0]
+                )
+                issuers = resp.get_issuers()
+            else:
+                assert saml_response_or_request[1] == "SAMLRequest"
+
+                # The only valid SAMLRequest we can receive is a LogoutRequest.
+                logout_request_xml = OneLogin_Saml2_Logout_Request(
+                    config, saml_response_or_request[0]
+                ).get_xml()
+                issuers = [OneLogin_Saml2_Logout_Request.get_issuer(logout_request_xml)]
         except self.SAMLRESPONSE_PARSING_EXCEPTIONS:
             self.logger.info("Error while parsing SAMLResponse:", exc_info=True)
             return None
@@ -2357,10 +2372,76 @@ def _check_entitlements(
         )
         raise AuthFailed(self, error_msg)
 
+    def process_logout(self, subdomain: str, idp_name: str) -> Optional[HttpResponse]:
+        """
+        We override process_logout, because we need to customize
+        the way of revoking sessions and introduce NameID validation.
+
+        The python-social-auth and python3-saml implementations expect a simple
+        callback function without arguments, to delete the session. We're not
+        happy with that for two reasons:
+        1. These implementations don't look at the NameID in the LogoutRequest, which
+           is not quite correct, as a LogoutRequest to logout user X can be delivered
+           through any means, and doesn't need a session to be valid.
+           E.g. a backchannel logout request sent by the IdP wouldn't have a session cookie.
+           Also, hypothetically, a LogoutRequest to logout user Y shouldn't logout user X, even if the
+           request is made with a session cookie belonging to user X.
+        2. We want to revoke all sessions for the user, not just the current session
+           of the request, so after validating the LogoutRequest, we need to identify
+           the user by the NameID, do some validation and then revoke all sessions.
+
+        TODO: This does not return a LogoutResponse in case of failure, like the spec requires.
+        https://github.com/zulip/zulip/issues/20076 is the related issue with more detail
+        on how to implement the desired behavior.
+        """
+        idp = self.get_idp(idp_name)
+        auth = self._create_saml_auth(idp)
+        # This validates the LogoutRequest and prepares the response
+        # (the URL to which to redirect the client to convey the response to the IdP)
+        # but is a no-op otherwise because keep_local_session=True keeps it from
+        # doing anything else. We want to take care of revoking session on our own.
+        url = auth.process_slo(keep_local_session=True)
+        errors = auth.get_errors()
+        if errors:
+            self.logger.info("/complete/saml/: LogoutRequest failed: %s", errors)
+            return None
+
+        logout_request_xml = auth.get_last_request_xml()
+        name_id = OneLogin_Saml2_Logout_Request.get_nameid(logout_request_xml)
+        try:
+            validate_email(name_id)
+        except ValidationError:
+            self.logger.info(
+                "/complete/saml/: LogoutRequest failed: NameID is not a valid email address: %s",
+                name_id,
+            )
+            return None
+
+        return_data: Dict[str, Any] = {}
+
+        realm = get_realm(subdomain)
+        user_profile = common_get_active_user(name_id, realm, return_data)
+        if user_profile is None:
+            self.logger.info(
+                "/complete/saml/: LogoutRequest failed: No user with email specified in NameID found in realm %s. return_data=%s",
+                realm.id,
+                return_data,
+            )
+            return None
+
+        self.logger.info(
+            "/complete/saml/: LogoutRequest triggered deletion of all session for user %s",
+            user_profile.id,
+        )
+        delete_user_sessions(user_profile)
+        do_regenerate_api_key(user_profile, user_profile)
+
+        return HttpResponseRedirect(url)
+
     def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]:
         """
         Additional ugly wrapping on top of auth_complete in SocialAuthMixin.
-        We handle two things here:
+        We handle two things for processing SAMLResponses here:
             1. Working around bad RelayState or SAMLResponse parameters in the request.
             Both parameters should be present if the user came to /complete/saml/ through
             the IdP as intended. The errors can happen if someone simply types the endpoint into
@@ -2370,26 +2451,35 @@ def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]:
             into the RelayState. We need to read them and set those values in the session,
             and then change the RelayState param to the idp_name, because that's what
             SAMLAuth.auth_complete() expects.
+
+        Additionally, this handles incoming LogoutRequests for IdP-initated logout.
         """
+
+        SAMLRequest = self.strategy.request_data().get("SAMLRequest")
         SAMLResponse = self.strategy.request_data().get("SAMLResponse")
-        if SAMLResponse is None:
-            self.logger.info("/complete/saml/: No SAMLResponse in request.")
+        if SAMLResponse is None and SAMLRequest is None:
+            self.logger.info("/complete/saml/: No SAMLResponse or SAMLRequest in request.")
             return None
+        elif SAMLRequest is not None:
+            saml_response_or_request = (SAMLRequest, "SAMLRequest")
+        elif SAMLResponse is not None:
+            saml_response_or_request = (SAMLResponse, "SAMLResponse")
 
         relayed_params = self.get_relayed_params()
 
         subdomain = self.choose_subdomain(relayed_params)
         if subdomain is None:
             error_msg = (
-                "/complete/saml/: Can't figure out subdomain for this authentication request. "
-                + "relayed_params: %s"
+                "/complete/saml/: Can't figure out subdomain for this %s. " + "relayed_params: %s"
             )
-            self.logger.info(error_msg, relayed_params)
+            self.logger.info(error_msg, saml_response_or_request[1], relayed_params)
             return None
 
-        idp_name = self.get_issuing_idp(SAMLResponse)
+        idp_name = self.get_issuing_idp(saml_response_or_request)
         if idp_name is None:
-            self.logger.info("/complete/saml/: No valid IdP as issuer of the SAMLResponse.")
+            self.logger.info(
+                "/complete/saml/: No valid IdP as issuer of the %s.", saml_response_or_request[1]
+            )
             return None
 
         idp_valid = self.validate_idp_for_subdomain(idp_name, subdomain)
@@ -2401,6 +2491,9 @@ def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]:
             self.logger.info(error_msg, idp_name, subdomain)
             return None
 
+        if saml_response_or_request[1] == "SAMLRequest":
+            return self.process_logout(subdomain, idp_name)
+
         result = None
         try:
             params = relayed_params.copy()
diff --git a/zproject/computed_settings.py b/zproject/computed_settings.py
--- a/zproject/computed_settings.py
+++ b/zproject/computed_settings.py
@@ -1129,6 +1129,13 @@ def zulip_path(path: str) -> str:
     default_signature_alg = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
     SOCIAL_AUTH_SAML_SECURITY_CONFIG["signatureAlgorithm"] = default_signature_alg
 
+if "wantMessagesSigned" not in SOCIAL_AUTH_SAML_SECURITY_CONFIG:
+    # This setting controls whether LogoutRequests delivered to us
+    # need to be signed. The default of False is not acceptable,
+    # because we don't want anyone to be able to submit a request
+    # to get other users logged out.
+    SOCIAL_AUTH_SAML_SECURITY_CONFIG["wantMessagesSigned"] = True
+
 for idp_name, idp_dict in SOCIAL_AUTH_SAML_ENABLED_IDPS.items():
     if DEVELOPMENT:
         idp_dict["entity_id"] = get_secret("saml_entity_id", "")


Test patch:
diff --git a/zerver/tests/fixtures/saml/logoutrequest.txt b/zerver/tests/fixtures/saml/logoutrequest.txt
new file mode 100644
--- /dev/null
+++ b/zerver/tests/fixtures/saml/logoutrequest.txt
@@ -0,0 +1 @@
+<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Destination="http://zulip.testserver/complete/saml/" ID="ID_fed666cd-2f56-48a8-89f6-cc34723c011a" IssueInstant="2021-10-21T11:15:18.548Z" Version="2.0"><saml:Issuer>https://idp.testshib.org/idp/shibboleth</saml:Issuer><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{email}</saml:NameID><samlp:SessionIndex>843cfae0-52d6-4ace-871d-78c61a81d4fb::2b1b41e9-f8c8-4114-ab1d-13a01889fb23</samlp:SessionIndex></samlp:LogoutRequest>
diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py
--- a/zerver/tests/test_auth_backends.py
+++ b/zerver/tests/test_auth_backends.py
@@ -29,7 +29,9 @@
 from django_auth_ldap.backend import LDAPSearch, _LDAPUser
 from jwt.exceptions import PyJWTError
 from onelogin.saml2.auth import OneLogin_Saml2_Auth
+from onelogin.saml2.logout_request import OneLogin_Saml2_Logout_Request
 from onelogin.saml2.response import OneLogin_Saml2_Response
+from onelogin.saml2.utils import OneLogin_Saml2_Utils
 from social_core.exceptions import AuthFailed, AuthStateForbidden
 from social_django.storage import BaseDjangoStorage
 from social_django.strategy import DjangoStrategy
@@ -1931,9 +1933,157 @@ def generate_saml_response(
 
         return saml_response
 
+    def generate_saml_logout_request_from_idp(self, email: str) -> str:
+        """
+        The logoutrequest.txt fixture has a pre-generated LogoutRequest,
+        with {email} placeholder, that can
+        be filled out with the data we want.
+        """
+        unencoded_logout_request = self.fixture_data("logoutrequest.txt", type="saml").format(
+            email=email,
+        )
+        logout_request: str = base64.b64encode(unencoded_logout_request.encode()).decode()
+
+        return logout_request
+
+    def make_idp_initiated_logout_request(
+        self, email: str, make_validity_checks_pass: bool = True
+    ) -> HttpResponse:
+        samlrequest = self.generate_saml_logout_request_from_idp(email)
+        parameters = {"SAMLRequest": samlrequest}
+
+        if make_validity_checks_pass:
+            # It's hard to create fully-correct LogoutRequests with signatures in tests,
+            # so we rely on mocking the validating functions instead.
+            with mock.patch.object(
+                OneLogin_Saml2_Logout_Request, "is_valid", return_value=True
+            ), mock.patch.object(
+                OneLogin_Saml2_Auth,
+                "validate_request_signature",
+                return_value=True,
+            ):
+                result = self.client_get("http://zulip.testserver/complete/saml/", parameters)
+        else:
+            result = self.client_get("http://zulip.testserver/complete/saml/", parameters)
+        return result
+
     def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]:
         return dict(email=email, name=name)
 
+    def test_saml_idp_initiated_logout_success(self) -> None:
+        hamlet = self.example_user("hamlet")
+        old_api_key = hamlet.api_key
+        self.login("hamlet")
+
+        self.assert_logged_in_user_id(hamlet.id)
+        result = self.make_idp_initiated_logout_request(hamlet.delivery_email)
+        self.assert_logged_in_user_id(None)
+
+        # The expected response is a redirect to the IdP's slo_url endpoint
+        # with a SAMLResponse announcing success.
+        self.assertEqual(result.status_code, 302)
+        redirect_to = result["Location"]
+        self.assertIn(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS["test_idp"]["slo_url"], redirect_to)
+
+        parsed = urllib.parse.urlparse(redirect_to)
+        query_dict = urllib.parse.parse_qs(parsed.query)
+
+        self.assertIn("SAMLResponse", query_dict)
+        # Do some very basic parsing of the SAMLResponse to verify it's a success response.
+        saml_response_encoded = query_dict["SAMLResponse"][0]
+        saml_response = OneLogin_Saml2_Utils.decode_base64_and_inflate(
+            saml_response_encoded
+        ).decode()
+        self.assertIn(
+            '<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />', saml_response
+        )
+
+        hamlet.refresh_from_db()
+        # Ensure that the user's api_key was rotated:
+        self.assertNotEqual(hamlet.api_key, old_api_key)
+
+    def test_saml_idp_initiated_logout_request_for_different_user(self) -> None:
+        """
+        This test verifies that sessions are revoked based on the NameID
+        in the LogoutRequest rather than just the logged in session cookie.
+        """
+        hamlet = self.example_user("hamlet")
+        cordelia = self.example_user("cordelia")
+        cordelia_old_api_key = cordelia.api_key
+        self.login("hamlet")
+
+        self.assert_logged_in_user_id(hamlet.id)
+        # We're logged in as hamlet, but deliver a LogoutRequest for cordelia.
+        # This means our session should not be affected.
+        self.make_idp_initiated_logout_request(cordelia.delivery_email)
+        self.assert_logged_in_user_id(hamlet.id)
+
+        cordelia.refresh_from_db()
+        # Cordelia's api_key should have been rotated:
+        self.assertNotEqual(cordelia.api_key, cordelia_old_api_key)
+
+    def test_saml_idp_initiated_logout_invalid_nameid_format(self) -> None:
+        hamlet = self.example_user("hamlet")
+        self.login("hamlet")
+
+        self.assert_logged_in_user_id(hamlet.id)
+        with self.assertLogs("zulip.auth.saml") as mock_logger:
+            # LogoutRequests need to have the email address in NameID
+            # so putting "hamlet" there is invalid.
+            result = self.make_idp_initiated_logout_request("hamlet")
+        self.assert_logged_in_user_id(hamlet.id)
+
+        self.assertEqual(
+            mock_logger.output,
+            [
+                "INFO:zulip.auth.saml:/complete/saml/: LogoutRequest failed: NameID is not a valid email address: hamlet"
+            ],
+        )
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(result["Location"], "/")
+
+    def test_saml_idp_initiated_logout_user_not_in_realm(self) -> None:
+        hamlet = self.example_user("hamlet")
+        self.login("hamlet")
+
+        self.assert_logged_in_user_id(hamlet.id)
+        with self.assertLogs("zulip.auth.saml") as mock_logger:
+            result = self.make_idp_initiated_logout_request("nonexistant@zulip.com")
+        self.assert_logged_in_user_id(hamlet.id)
+
+        self.assertEqual(
+            mock_logger.output,
+            [
+                "INFO:zulip.auth.saml:/complete/saml/: LogoutRequest failed: No user with email specified in NameID found in realm 2. return_data={}"
+            ],
+        )
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(result["Location"], "/")
+
+    def test_saml_idp_initiated_logout_invalid_signature(self) -> None:
+        hamlet = self.example_user("hamlet")
+        self.login("hamlet")
+
+        self.assert_logged_in_user_id(hamlet.id)
+        with self.assertLogs("zulip.auth.saml") as mock_logger:
+            # LogoutRequests  we generate in tests don't have signatures. We can use
+            # the make_validity_checks_pass argument to disable mocking of python3-saml
+            # internal validation functions to make validation of our LogoutRequest fail
+            # and test our error-handling of that.
+            result = self.make_idp_initiated_logout_request(
+                hamlet.delivery_email, make_validity_checks_pass=False
+            )
+        self.assert_logged_in_user_id(hamlet.id)
+
+        self.assertEqual(
+            mock_logger.output,
+            [
+                "INFO:zulip.auth.saml:/complete/saml/: LogoutRequest failed: ['invalid_logout_request_signature', 'Signature validation failed. Logout Request rejected']"
+            ],
+        )
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(result["Location"], "/")
+
     def test_auth_registration_with_no_name_provided(self) -> None:
         """
         The SAMLResponse may not actually provide name values, which is considered
@@ -2085,7 +2235,12 @@ def test_social_auth_complete_bad_params(self) -> None:
             self.assertEqual(result.status_code, 302)
             self.assertIn("login", result.url)
         self.assertEqual(
-            m.output, [self.logger_output("/complete/saml/: No SAMLResponse in request.", "info")]
+            m.output,
+            [
+                self.logger_output(
+                    "/complete/saml/: No SAMLResponse or SAMLRequest in request.", "info"
+                )
+            ],
         )
 
         # Check that POSTing the RelayState, but with missing SAMLResponse,
@@ -2101,7 +2256,12 @@ def test_social_auth_complete_bad_params(self) -> None:
             self.assertEqual(result.status_code, 302)
             self.assertIn("login", result.url)
         self.assertEqual(
-            m.output, [self.logger_output("/complete/saml/: No SAMLResponse in request.", "info")]
+            m.output,
+            [
+                self.logger_output(
+                    "/complete/saml/: No SAMLResponse or SAMLRequest in request.", "info"
+                )
+            ],
         )
 
         # Now test bad SAMLResponses.
@@ -2160,7 +2320,7 @@ def test_social_auth_complete_no_subdomain(self) -> None:
             m.output,
             [
                 self.logger_output(
-                    "/complete/saml/: Can't figure out subdomain for this authentication request. relayed_params: {}".format(
+                    "/complete/saml/: Can't figure out subdomain for this SAMLResponse. relayed_params: {}".format(
                         "{}"
                     ),
                     "info",
@@ -2493,7 +2653,7 @@ def test_idp_initiated_signin_subdomain_implicit_invalid(self) -> None:
             m.output,
             [
                 self.logger_output(
-                    "/complete/saml/: Can't figure out subdomain for this authentication request. relayed_params: {}",
+                    "/complete/saml/: Can't figure out subdomain for this SAMLResponse. relayed_params: {}",
                     "info",
                 )
             ],
diff --git a/zproject/test_extra_settings.py b/zproject/test_extra_settings.py
--- a/zproject/test_extra_settings.py
+++ b/zproject/test_extra_settings.py
@@ -250,6 +250,7 @@ def set_loglevel(logger_name: str, level: str) -> None:
     "test_idp": {
         "entity_id": "https://idp.testshib.org/idp/shibboleth",
         "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+        "slo_url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/Logout",
         "x509cert": get_from_file_if_exists("zerver/tests/fixtures/saml/idp.crt"),
         "attr_user_permanent_id": "email",
         "attr_first_name": "first_name",