SAML Single Logout (SLO)

Posted . Visible to the public.

There are two ways a logout in SAML can happen: Service Provider (SP) initiated and Identity Provider (IDP) initiated logout. I'll explain how to implement both flows with devise_saml_authenticatable.

Note

SAML also supports a SOAP and an Artifact binding to do this. This guide only refers to POST and Redirect bindings. devise_saml_authenticatable does not support SOAP and Artifact bindings.

SP initiated logout (using the Redirect Binding)

When the user clicks on Logout within the app, the app can trigger a logout for the user in the IDP.
This way the user does not get immediately reauthenticated when he visits the app again, but gets redirected to the IDP login mask.
This might be necessary, if the user has multiple accounts within the IDP he needs to switch between, which is not really practical without a SP initiated logout.

Flow:

  1. The user clicks Logout within the app
  2. This triggers a DELETE /users/sign_out (at least for devise_saml_authenticatable, others might differ)
  3. The server generates a SAML LogoutRequest and responds with a redirect the the SingleLogoutService HTTP-Redirect Binding (found in the IDP metadata). The LogoutRequest is contained in the redirect URL params as an URL, base64 encoded XML document.
  4. The Browser follows the redirect to the IDP
  5. The IDP checks the request, logs out the user from itself
  6. The IDP generates a SAML LogoutResponse and redirects the user back the app. The LogoutResponse is contained in the redirect URL params as a URL, base64 encoded
    xml document.
    The URL für this redirect needs to be configured at the IDP. For example in keycloak it is the Logout Service Redirect Binding URL found in the Advanced tab of the client.
  7. The app server could now parse the LogoutResponse, if necessary. It doesn't contain a lot, mainly some general information about the IDP and a status, if the logout at the IDP was successful. The session for the app is already destroyed upon DELETE /users/sign_out, so there isn't really anything we need to do there.

Note

There is another way this can be done: The IDP can be configured to perform a backchannel logout when receiving a SP initiated logout. This changes the flow from 6. onwards: Instead of redirecting the user back with a LogoutResponse, the IDP performs a IDP initiated logout like described below. devise_saml_authenticatable actually does nothing in that case, as the session within the app already has been destroyed before sending the Request to IDP. Then the user gets redirected to the Logout Service POST Binding URL with the LogoutResponse.
Keycloak does this when Front channel logout is disabled.

Testing in development using a local keycloak server

You can test this using a local keycloak server.

You need to configure:

  • Client -> Settings -> Valid post logout redirect URIs: *
  • Client -> Advanced -> Logout Service Redirect Binding URL: The url to redirect to after a logout

Troubleshooting when using devise_saml_authenticatable

devise_saml_authenticatable does a SP initiated logout by default, if not otherwise configured. But there are some steps I had to take in order to make it work.

Don´t overwrite #after_sign_out_path_for in the SamlSessionsController

In this method the LogoutRequest gets generated and returned alongside the SingleLogoutService URL. If this does not happen, obviously no logout at the IDP will occur.
But the normal logout within the app does happen, so it might seam confusing why the IDP logout is not working.

Calling current_user within the after logout route results in an exception

This might show up as a exception Issuer of the Response not found or multiple. from within RubySaml.

devise_saml_authenticatable (at least up to version 1.9.1) has a bug there:
If you call current_user when a LogoutResponse is present as an URL param it fails to parse it and throws an error. devise_saml_authenticatable tries to parse the LogoutResponse as a normal SAML Response and fails because a LogoutResponse is just different.
As there should never be a current user present for this route, I solved this by overwriting #current_user within the SamlSessionsController and just returning nil for those routes:

  def current_user
    super unless params[:action].in? %w[after_logout idp_sign_out]
  end

Unsafe redirect when trying to log out

Since Rails 7 you need to pass allow_other_host: true to redirect_to to allow a redirect to other hosts than the configured app hosts.
Since we need to redirect to the IDP on logout and devise does not do that we get a

Unsafe redirect to "https://some-idp.example.com/realms/dev/protocol/saml?SAMLRequest=fZJBb9QwEIX%2FSm45zcZx4j...", pass allow_other_host: true to redirect anyway.

upon trying to logout.

See https://github.com/apokalipto/devise_saml_authenticatable/issues/237 Show archive.org snapshot and https://github.com/heartcombo/devise/pull/5462 Show archive.org snapshot . This might get fixed in the future, so check if this patch is still necessary.

I fixed this by patching #respond_to_on_destroy within the SamlSessionsController and adding the allow_other_host: true

  # Patched to allow to for redirect to the idp. Can be removed once https://github.com/heartcombo/devise/pull/5462 is merged and released.
  def respond_to_on_destroy
    raise 'Check if this patch is still needed.' unless Devise::VERSION == '4.9.3'

    respond_to do |format|
      format.all { head :no_content }
      format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name), allow_other_host: true, status: Devise.responder.redirect_status }
    end
  end

IDP initiated logout

SAML also supports a logout initiated from the IDP side. This will destroy the user session within the app when someone clicks a button within the IDP or the user uses SLO within another SP connected to the IDP, depending on the configuration at the IDP.
A little documentation for devise_saml_authenticatable: https://github.com/apokalipto/devise_saml_authenticatable?tab=readme-ov-file#logout-request Show archive.org snapshot

Flow

  1. The IDP send a POST with a SAML LogoutRequest to the appserver to a, at the IDP, configured URL (For keycloak the Logout Service POST Binding URL)
  2. The appserver parses the response and destroys the users session (There obviously is no user browser involved in this)

How to enable

  • You need to configure config.saml_session_index_key = :saml_session within the devise config, so the appserver can find the user from the LogoutRequest
  • The route for this is /users/saml/idp_sign_out (for devise_saml_authenticatable) and is enabled per default
  • This just worked for me, so no troubleshooting here

Testing in development using a local keycloak server

You can test this using a local keycloak server.

You need to configure:

  • Client -> Advanced -> Logout Service POST Binding URL: The url to receive the logout request, e.g. for devise_saml_authenticatable this would be http://localhost:3000/users/saml/idp_sign_out
  • Client -> Settings -> Front channel logout (all the way at the bottom): Turn this off

Now login using the IDP and you should see a session for the user within Sessions in keycloak. Click the three dots for the session you want to terminate and choose Sign out. The appserver should receive a POST to the configured URL and destroy the user´s session. When reloading the tab with the app, you should see the IDP login mask again.

Martin Schaflitzl
Last edit
Martin Schaflitzl
License
Source code in this card is licensed under the MIT License.
Posted by Martin Schaflitzl to makandra dev (2024-04-15 14:35)