From 446a6ccf14f2e868de60a0c80a2c824d80072d6a Mon Sep 17 00:00:00 2001
From: "metabase-bot[bot]"
 <109303359+metabase-bot[bot]@users.noreply.github.com>
Date: Tue, 5 Mar 2024 00:53:35 +0000
Subject: [PATCH] Saml2 slo (#39034) (#39588)

* wip

* SLO works with auth slo handler route

* move slo handling endpoint to /auth/sso/handle_slo

* fix slo redirect url

* SLO works, and the sso-handle-slo for saml is where it belongs

- a ton of cleanup

* fix api/session namespace + add docstrings

* cleaning up logout action

* add slo logout test along with slo response xml

* whitespace + linter

* add docstring

* update exclusions in deps.edn

* un-require metabase-enterprise ns from oss ns

* add docs for how to setup SLO to metabase docs

* docs: clarify that setting up SLO is optional

* move slo logout endpoint into ee code

- removes sso-info defenterprise since it is no longer needed

* use current version of saml20-clj

---------

Co-authored-by: bryan <bryan.maass@gmail.com>
Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
---
 .lsp/config.edn                               |  2 +-
 deps.edn                                      |  5 +-
 docs/people-and-groups/saml-keycloak.md       |  5 +-
 .../sandbox/api/error_page.mustache           |  2 +-
 .../metabase_enterprise/sso/api/interface.clj |  9 +++
 .../src/metabase_enterprise/sso/api/sso.clj   | 63 ++++++++++++++++---
 .../sso/integrations/saml.clj                 | 60 +++++++++++++++---
 .../sso/integrations/saml_test.clj            | 23 ++++++-
 frontend/src/metabase-types/api/mocks/user.ts |  1 +
 frontend/src/metabase-types/api/user.ts       |  1 +
 frontend/src/metabase/auth/actions.ts         | 34 +++++++---
 frontend/src/metabase/lib/auth.js             | 10 +++
 frontend/src/metabase/services.js             |  1 +
 src/metabase/server/middleware/session.clj    |  3 +-
 test/metabase/api/session_test.clj            |  2 +-
 .../server/middleware/session_test.clj        |  2 +-
 test_resources/saml-slo-test-response.xml     | 35 +++++++++++
 17 files changed, 224 insertions(+), 34 deletions(-)
 create mode 100644 test_resources/saml-slo-test-response.xml

diff --git a/.lsp/config.edn b/.lsp/config.edn
index 42316090154..0e767070d94 100644
--- a/.lsp/config.edn
+++ b/.lsp/config.edn
@@ -2,7 +2,7 @@
  :show-docs-arity-on-same-line? true
  :project-specs                 [{:project-path "deps.edn"
                                   :classpath-cmd ["clojure"
-                                                  "-A:dev:ee:ee-dev:drivers:drivers-dev:build:build/release:build/all"
+                                                  "-A:dev:ee:ee-dev:drivers:drivers-dev:build:build/all"
                                                   "-Spath"]}]
  :clean {:ns-inner-blocks-indentation :keep}
  :linters {:clojure-lsp/unused-public-var {:level :warning
diff --git a/deps.edn b/deps.edn
index 270b316f53d..16674fde416 100644
--- a/deps.edn
+++ b/deps.edn
@@ -88,8 +88,9 @@
   lambdaisland/uri                          {:mvn/version "1.19.155"}           ; Used by openai-clojure
   medley/medley                             {:mvn/version "1.4.0"}              ; lightweight lib of useful functions
   metabase/connection-pool                  {:mvn/version "1.2.0"}              ; simple wrapper around C3P0. JDBC connection pools
-  metabase/saml20-clj                       {:mvn/version "2.2.4"               ; EE SAML integration.
-                                             :exclusions [org.bouncycastle/bcpkix-jdk15on
+  metabase/saml20-clj                       {:mvn/version "2.2.7.170"
+                                             :exclusions [                      ; EE SAML integration TODO: bump version when we release the library
+                                                          org.bouncycastle/bcpkix-jdk15on
                                                           org.bouncycastle/bcprov-jdk15on
                                                           org.bouncycastle/bcpkix-jdk18on
                                                           org.bouncycastle/bcprov-jdk18on]}
diff --git a/docs/people-and-groups/saml-keycloak.md b/docs/people-and-groups/saml-keycloak.md
index aca1e996472..51decb6a701 100644
--- a/docs/people-and-groups/saml-keycloak.md
+++ b/docs/people-and-groups/saml-keycloak.md
@@ -35,7 +35,10 @@ For more information, check out our guide for [authenticating with SAML](./authe
 7. Configure the service provider (Metabase) from **Configure** > **Realm Settings**.
     - From **Endpoints**, select “SAML 2.0 Identity Provider Metadata”.
     - An XML file will open in a new tab.
-8. From the XML file, note the following:
+8. Configure the Single Logout service (if you intend to use Single Logout)
+    1. in **Clients** > **Valid post logout redirect URIs** put your server's uri (this usually matches **Valid redirect URIs**)
+    2. in **Clients** > **Advanced** > **Logout Service POST Binding URL** put your server URI appended with `/auth/sso/handle_slo`
+9. From the XML file, note the following:
     1. The URL that appears right after the following string:
     ```
     md:SingleSignOnServiceBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location=
diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/error_page.mustache b/enterprise/backend/src/metabase_enterprise/sandbox/api/error_page.mustache
index da1cea01f41..19154e571e9 100644
--- a/enterprise/backend/src/metabase_enterprise/sandbox/api/error_page.mustache
+++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/error_page.mustache
@@ -8,7 +8,7 @@
       <div style="width:1000px;">
         <img src="../app/assets/img/disconnect.svg">
 
-        <h2>It looks like we weren't able to log you in.</h2>
+        <h2>It looks like we weren't able to log you {{logDirection}}.</h2>
 
         <h2><tt>{{errorMessage}}</tt></h2>
 
diff --git a/enterprise/backend/src/metabase_enterprise/sso/api/interface.clj b/enterprise/backend/src/metabase_enterprise/sso/api/interface.clj
index c80d1b8ea74..26a829846e1 100644
--- a/enterprise/backend/src/metabase_enterprise/sso/api/interface.clj
+++ b/enterprise/backend/src/metabase_enterprise/sso/api/interface.clj
@@ -22,6 +22,11 @@
   validate the POST from the SSO backend and successfully log the user into Metabase."
   sso-backend)
 
+(defmulti sso-handle-slo
+  "Multi-method for handling a SLO request from an SSO backend. An implementation of this method will need to validate
+  the SLO request and log the user out of Metabase."
+  sso-backend)
+
 (defn- throw-not-configured-error []
   (throw (ex-info (str (tru "SSO has not been enabled and/or configured"))
                   {:status-code 400})))
@@ -33,3 +38,7 @@
 (defmethod sso-post :default
   [_]
   (throw-not-configured-error))
+
+(defmethod sso-handle-slo :default
+  [_]
+  (throw-not-configured-error))
diff --git a/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj b/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj
index 50abb67e678..00ac09ef1b2 100644
--- a/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj
+++ b/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj
@@ -8,11 +8,17 @@
    [metabase-enterprise.sso.api.interface :as sso.i]
    [metabase-enterprise.sso.integrations.jwt]
    [metabase-enterprise.sso.integrations.saml]
+   [metabase-enterprise.sso.integrations.sso-settings :as sso-settings]
    [metabase.api.common :as api]
    [metabase.util :as u]
    [metabase.util.i18n :refer [trs]]
    [metabase.util.log :as log]
-   [stencil.core :as stencil]))
+   [metabase.util.malli :as mu]
+   [metabase.util.urls :as urls]
+   [saml20-clj.core :as saml]
+   [saml20-clj.encode-decode :as encode-decode]
+   [stencil.core :as stencil]
+   [toucan2.core :as t2]))
 
 (set! *warn-on-reflection* true)
 
@@ -20,6 +26,7 @@
 (comment metabase-enterprise.sso.integrations.jwt/keep-me
          metabase-enterprise.sso.integrations.saml/keep-me)
 
+;; GET /auth/sso
 (api/defendpoint GET "/"
   "SSO entry-point for an SSO user that has not logged in yet"
   [:as req]
@@ -29,16 +36,19 @@
       (log/error #_e (trs "Error returning SSO entry point"))
       (throw e))))
 
-(defn- sso-error-page [^Throwable e]
+(mu/defn ^:private sso-error-page
+  [^Throwable e log-direction :- [:enum "in" "out"]]
   {:status  (get (ex-data e) :status-code 500)
    :headers {"Content-Type" "text/html"}
    :body    (stencil/render-file "metabase_enterprise/sandbox/api/error_page"
-              (let [message    (.getMessage e)
-                    data       (u/pprint-to-str (ex-data e))]
-                {:errorMessage   message
-                 :exceptionClass (.getName Exception)
-                 :additionalData data}))})
+                                 (let [message    (.getMessage e)
+                                       data       (u/pprint-to-str (ex-data e))]
+                                   {:logDirection   log-direction
+                                    :errorMessage   message
+                                    :exceptionClass (.getName Exception)
+                                    :additionalData data}))})
 
+;; POST /auth/sso
 (api/defendpoint POST "/"
   "Route the SSO backends call with successful login details"
   [:as req]
@@ -46,6 +56,43 @@
     (sso.i/sso-post req)
     (catch Throwable e
       (log/error e (trs "Error logging in"))
-      (sso-error-page e))))
+      (sso-error-page e "in"))))
+
+
+;; ------------------------------ Single Logout aka SLO ------------------------------
+
+(def metabase-slo-redirect-url
+  "The url that the IdP should respond to. Not all IdPs support this, but it's a good idea to send it just in case."
+  "/auth/sso/handle_slo")
+
+;; POST /auth/sso/logout
+(api/defendpoint POST "/logout"
+  "Logout."
+  [:as {:keys [metabase-session-id]}]
+  (api/check-exists? :model/Session metabase-session-id)
+  (let [{:keys [email sso_source]}
+        (t2/query-one {:select [:u.email :u.sso_source]
+                       :from   [[:core_user :u]]
+                       :join   [[:core_session :session] [:= :u.id :session.user_id]]
+                       :where  [:= :session.id metabase-session-id]})]
+    {:saml-logout-url
+     (when (and (sso-settings/saml-enabled)
+                (= sso_source "saml"))
+       (saml/logout-redirect-location
+        :idp-url (sso-settings/saml-identity-provider-uri)
+        :issuer (sso-settings/saml-application-name)
+        :user-email email
+        :relay-state (encode-decode/str->base64
+                      (str (urls/site-url) metabase-slo-redirect-url))))}))
+
+;; POST /auth/sso/handle_slo
+(api/defendpoint POST "/handle_slo"
+  "Handles client confirmation of saml logout via slo"
+  [:as req]
+  (try
+    (sso.i/sso-handle-slo req)
+    (catch Throwable e
+      (log/error e (trs "Error handling SLO"))
+      (sso-error-page e "out"))))
 
 (api/define-routes)
diff --git a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj
index fc1d4cc318a..5c0c9fa5b34 100644
--- a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj
+++ b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj
@@ -1,24 +1,38 @@
 (ns metabase-enterprise.sso.integrations.saml
   "Implementation of the SAML backend for SSO.
 
-  The basic flow of of a SAML login is:
+  # The basic flow of of a SAML login is:
 
-  1. User attempts to access some `url` but is not authenticated
+  1. User attempts to access some `url` but is not authenticated.
 
-  2. User is redirected to `GET /auth/sso`
+  2. User is redirected to `GET /auth/sso`.
 
-  3. Metabase issues another redirect to the identity provider URI
+  3. Metabase issues another redirect to the identity provider URI.
 
-  4. User logs into their identity provider (i.e. Auth0)
+  4. User logs into their identity provider (i.e. Auth0).
 
-  5. Identity provider POSTs to Metabase with successful auth info
+  5. Identity provider POSTs to Metabase with successful auth info.
 
-  6. Metabase parses/validates the SAML response
+  6. Metabase parses/validates the SAML response.
 
-  7. Metabase inits the user session, responds with a redirect to back to the original `url`"
+  7. Metabase inits the user session, responds with a redirect to back to the original `url`.
+
+  # The basic flow of a SAML logout is:
+
+  1. A SSO SAML User clicks Sign Out.
+
+  2. Metabase issues a redirect to the client with a LogoutRequest to the identity provider.
+
+  3. Client forwards the request to the identity provider.
+
+  4. Identity provider logs the user out + redirects client back to Metabase with a LogoutResponse.
+
+  5. Metabase checks for successful LogoutResponse, clears the user's session, and responds to the client with a redirect to the home page."
   (:require
    [buddy.core.codecs :as codecs]
+   [clojure.data.xml :as xml]
    [clojure.string :as str]
+   [clojure.walk :as walk]
    [java-time.api :as t]
    [medley.core :as m]
    [metabase-enterprise.sso.api.interface :as sso.i]
@@ -34,9 +48,12 @@
    [metabase.util :as u]
    [metabase.util.i18n :refer [trs tru]]
    [metabase.util.log :as log]
+   [metabase.util.malli :as mu]
+   [metabase.util.urls :as urls]
    [ring.util.response :as response]
    [saml20-clj.core :as saml]
-   [schema.core :as s])
+   [schema.core :as s]
+   [toucan2.core :as t2])
   (:import
    (java.net URI URISyntaxException)
    (java.util Base64 UUID)))
@@ -216,3 +233,28 @@
                            :device-info     (request.u/device-info request)})
           response      (response/redirect (or continue-url (public-settings/site-url)))]
       (mw.session/set-session-cookies request response session (t/zoned-date-time (t/zone-id "GMT"))))))
+
+(def ^:private saml2-success-status "urn:oasis:names:tc:SAML:2.0:status:Success")
+
+(mu/defn slo-success? :- :boolean
+  "Given a slo request saml response, return true if the response is successful."
+  [xml-str]
+  (let [*success? (atom false)]
+    (walk/postwalk
+     (fn [x]
+       (when (and (map? x)
+                  (= (:tag x) :StatusCode)
+                  (= (get-in x [:attrs :Value]) saml2-success-status))
+         (reset! *success? true))
+       x)
+     (xml/parse-str xml-str))
+    @*success?))
+
+(defmethod sso.i/sso-handle-slo :saml
+  [{:keys [cookies params]}]
+  (let [xml-str (base64-decode (:SAMLResponse params))
+        success? (slo-success? xml-str)]
+    (if-let [metabase-session-id (and success? (get-in cookies [mw.session/metabase-session-cookie :value]))]
+      (do (t2/delete! :model/Session :id metabase-session-id)
+          (mw.session/clear-session-cookie (response/redirect (urls/site-url))))
+      {:status 500 :body "SAML logout failed."})))
diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj
index cf9bbce18b9..9dd73a8bedd 100644
--- a/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj
@@ -102,7 +102,7 @@
 (defn successful-login?
   "Return true if the response indicates a successful user login"
   [resp]
-  (string? (get-in resp [:cookies @#'mw.session/metabase-session-cookie :value])))
+  (string? (get-in resp [:cookies mw.session/metabase-session-cookie :value])))
 
 (defn- do-with-some-validators-disabled
   "The sample responses all have `InResponseTo=\"_1\"` and invalid assertion signatures (they were edited by hand) so
@@ -273,6 +273,9 @@
 (defn- new-user-with-groups-in-separate-attribute-nodes-saml-test-response []
   (saml-response-from-file "test_resources/saml-test-response-new-user-with-groups-in-separate-attribute-nodes.xml"))
 
+(defn- saml-slo-test-response []
+  (saml-response-from-file "test_resources/saml-slo-test-response.xml"))
+
 (defn- saml-post-request-options [saml-response relay-state]
   {:request-options {:content-type     :x-www-form-urlencoded
                      :redirect-strategy :none
@@ -650,3 +653,21 @@
           (#'saml.mt/fetch-or-create-user! {:first-name "Test"
                                             :last-name  "user"
                                             :email      "test1234@metabsae.com"})))))))
+
+(deftest logout-should-delete-session-test
+  (testing "Successful SAML SLO logouts should delete the user's session."
+    (let [session-id (str (random-uuid))]
+      (mt/with-temp [:model/User user {:email "saml_test@metabase.com" :sso_source "saml"}
+                     :model/Session _ {:user_id (:id user) :id session-id}]
+        (with-saml-default-setup
+          (is (t2/exists? :model/Session :id session-id))
+          (let [req-options (-> (saml-post-request-options
+                                 (saml-slo-test-response)
+                                 (saml/str->base64 default-redirect-uri))
+                                ;; Client sends their session cookie during the SLO request redirect from the IDP.
+                                (assoc-in [:request-options :cookies mw.session/metabase-session-cookie :value] session-id))
+                response    (client-full-response :post 302 "/auth/sso/handle_slo" req-options)]
+            (is (str/blank? (get-in response [:cookies mw.session/metabase-session-cookie :value]))
+                "After a successful log-out, you don't have a session")
+            (is (not (t2/exists? :model/Session :id session-id))
+                "After a successful log-out, the session is deleted")))))))
diff --git a/frontend/src/metabase-types/api/mocks/user.ts b/frontend/src/metabase-types/api/mocks/user.ts
index 9e1e18718b6..e629b480dac 100644
--- a/frontend/src/metabase-types/api/mocks/user.ts
+++ b/frontend/src/metabase-types/api/mocks/user.ts
@@ -20,6 +20,7 @@ export const createMockUser = (opts?: Partial<User>): User => ({
   date_joined: new Date().toISOString(),
   first_login: new Date().toISOString(),
   last_login: new Date().toISOString(),
+  sso_source: null,
   ...opts,
 });
 
diff --git a/frontend/src/metabase-types/api/user.ts b/frontend/src/metabase-types/api/user.ts
index f8d712de1ba..ff0e171e2fe 100644
--- a/frontend/src/metabase-types/api/user.ts
+++ b/frontend/src/metabase-types/api/user.ts
@@ -30,6 +30,7 @@ export interface User extends BaseUser {
   has_invited_second_user: boolean;
   has_question_and_dashboard: boolean;
   personal_collection_id: number;
+  sso_source: "saml" | null;
   custom_homepage: {
     dashboard_id: DashboardId;
   } | null;
diff --git a/frontend/src/metabase/auth/actions.ts b/frontend/src/metabase/auth/actions.ts
index 1654f311eb3..ed2f82dd98d 100644
--- a/frontend/src/metabase/auth/actions.ts
+++ b/frontend/src/metabase/auth/actions.ts
@@ -1,7 +1,7 @@
 import { getIn } from "icepick";
 import { push } from "react-router-redux";
 
-import { deleteSession } from "metabase/lib/auth";
+import { deleteSession, initiateSLO } from "metabase/lib/auth";
 import { reload, isSmallScreen } from "metabase/lib/dom";
 import { loadLocalization } from "metabase/lib/i18n";
 import { createAsyncThunk } from "metabase/lib/redux";
@@ -99,14 +99,32 @@ export const loginGoogle = createAsyncThunk(
 export const LOGOUT = "metabase/auth/LOGOUT";
 export const logout = createAsyncThunk(
   LOGOUT,
-  async (redirectUrl: string | undefined, { dispatch, rejectWithValue }) => {
+  async (
+    redirectUrl: string | undefined,
+    { dispatch, rejectWithValue, getState },
+  ) => {
     try {
-      await deleteSession();
-      dispatch(clearCurrentUser());
-      await dispatch(refreshLocale()).unwrap();
-      trackLogout();
-      dispatch(push(Urls.login(redirectUrl)));
-      reload(); // clears redux state and browser caches
+      const state = getState();
+      const user = getUser(state);
+
+      if (user?.sso_source === "saml") {
+        const { "saml-logout-url": samlLogoutUrl } = await initiateSLO();
+
+        dispatch(clearCurrentUser());
+        await dispatch(refreshLocale()).unwrap();
+        trackLogout();
+
+        if (samlLogoutUrl) {
+          window.location.href = samlLogoutUrl;
+        }
+      } else {
+        await deleteSession();
+        dispatch(clearCurrentUser());
+        await dispatch(refreshLocale()).unwrap();
+        trackLogout();
+        dispatch(push(Urls.login()));
+        reload(); // clears redux state and browser caches
+      }
     } catch (error) {
       return rejectWithValue(error);
     }
diff --git a/frontend/src/metabase/lib/auth.js b/frontend/src/metabase/lib/auth.js
index 658766457c1..c196f790d1d 100644
--- a/frontend/src/metabase/lib/auth.js
+++ b/frontend/src/metabase/lib/auth.js
@@ -9,3 +9,13 @@ export const deleteSession = async () => {
     }
   }
 };
+
+export const initiateSLO = async () => {
+  try {
+    return await SessionApi.slo();
+  } catch (error) {
+    if (error.status !== 404) {
+      console.error("Problem clearing session", error);
+    }
+  }
+};
diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js
index 43f71aa63d4..083f369eecc 100644
--- a/frontend/src/metabase/services.js
+++ b/frontend/src/metabase/services.js
@@ -441,6 +441,7 @@ export const SessionApi = {
   create: POST("/api/session"),
   createWithGoogleAuth: POST("/api/session/google_auth"),
   delete: DELETE("/api/session"),
+  slo: POST("/auth/sso/logout"),
   properties: GET("/api/session/properties"),
   forgot_password: POST("/api/session/forgot_password"),
   reset_password: POST("/api/session/reset_password"),
diff --git a/src/metabase/server/middleware/session.clj b/src/metabase/server/middleware/session.clj
index 23a95af4332..71519f449e1 100644
--- a/src/metabase/server/middleware/session.clj
+++ b/src/metabase/server/middleware/session.clj
@@ -46,7 +46,8 @@
   (:import
    (java.util UUID)))
 
-(def ^:private ^String metabase-session-cookie          "metabase.SESSION")
+(def ^String metabase-session-cookie
+  "Where the session cookie goes."                      "metabase.SESSION")
 (def ^:private ^String metabase-embedded-session-cookie "metabase.EMBEDDED_SESSION")
 (def ^:private ^String metabase-session-timeout-cookie  "metabase.TIMEOUT")
 (def ^:private ^String anti-csrf-token-header           "x-metabase-anti-csrf-token")
diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj
index b0f360c441e..efceff94243 100644
--- a/test/metabase/api/session_test.clj
+++ b/test/metabase/api/session_test.clj
@@ -39,7 +39,7 @@
   [:map
    [:id ms/UUIDString]])
 
-(def ^:private session-cookie @#'mw.session/metabase-session-cookie)
+(def ^:private session-cookie mw.session/metabase-session-cookie)
 
 (deftest login-test
   (reset-throttlers!)
diff --git a/test/metabase/server/middleware/session_test.clj b/test/metabase/server/middleware/session_test.clj
index 265e9dfa1d1..0e6bd4184c2 100644
--- a/test/metabase/server/middleware/session_test.clj
+++ b/test/metabase/server/middleware/session_test.clj
@@ -31,7 +31,7 @@
 (set! *warn-on-reflection* true)
 
 
-(def ^:private session-cookie @#'mw.session/metabase-session-cookie)
+(def ^:private session-cookie mw.session/metabase-session-cookie)
 (def ^:private session-timeout-cookie @#'mw.session/metabase-session-timeout-cookie)
 
 (def ^:private test-uuid #uuid "092797dd-a82a-4748-b393-697d7bb9ab65")
diff --git a/test_resources/saml-slo-test-response.xml b/test_resources/saml-slo-test-response.xml
new file mode 100644
index 00000000000..1b27612333c
--- /dev/null
+++ b/test_resources/saml-slo-test-response.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<samlp:LogoutResponse 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://localhost:3000/auth/sso/handle_slo"
+                      ID="ID_34f419f0-a7c4-4a93-9bc0-e53efc65bbb3"
+                      InResponseTo="id67c2efc8-c92d-4c65-8974-470953cb49dd"
+                      IssueInstant="2024-02-22T18:32:52.132Z"
+                      Version="2.0"
+                      >
+    <Issuer>http://localhost:9090/realms/master</Issuer>
+    <dsig:Signature xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
+        <dsig:SignedInfo>
+            <dsig:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
+            <dsig:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
+            <dsig:Reference URI="#ID_34f419f0-a7c4-4a93-9bc0-e53efc65bbb3">
+                <dsig:Transforms>
+                    <dsig:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
+                    <dsig:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
+                </dsig:Transforms>
+                <dsig:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
+                <dsig:DigestValue>35Apmql7ucZdh7rVgwEwrmJQGDqqlHMiss2YV4q9Q5U=</dsig:DigestValue>
+            </dsig:Reference>
+        </dsig:SignedInfo>
+        <dsig:SignatureValue>oL0dRK4iXsRVFrW6lASYb2/ra4/0JMbhVEBQn9aVQHT+eBeSgpmYskEk6oeK4G1+dGFsSPgP8Sa6YAm8YyMyG2oqjrnCRAicJcJ0+tQIyztpK9lUNZeFkrig/tcVaRWu2Y1vyBAUeZRdhPH3HZN3AjDovJTL5ziwI+NkukGtt9xa4F+d/qHNbfM7IIe5mwHj2IWweeulIz8IxvsgiFAu4vWek+b75ykiNiLuJfEvJf+Gdt2IQHD1O8JftMI/RXK4mnjA8VHVYHsoTnWjhFLLCsIsVhAPoZeegp6e05ozPEt+MCCLQ76QF5HtjeeX6UjbIP7ADbohFOnO9tw7Vv9u/w==</dsig:SignatureValue>
+        <dsig:KeyInfo>
+            <dsig:X509Data>
+                <dsig:X509Certificate>MIICmzCCAYMCBgGNix9hpjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQwMjA4MjM0NjQ2WhcNMzQwMjA4MjM0ODI2WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCnd6BZgurzPoaBJPmbF/PqZynVsJ8srpdvIN/VKolZTavAl+vYq9L+a6NI21pV9K5rw8EwSm9uO+4tADoOP0av+Ku1r9DsuAY/NsOSdN0kRN32k3ADtaYsx4yIBQaNB6MLojM8XC6ajUvJj49zSoJhr7CGx1NF0zNdn9xzy+VmMANl+OVJ3Ek2QBmvsoO2sN3+iekZ92Te/q4iOFSzcG7nEN6APnH41oXsY2CWp8pqhOkntFKsYKJq8igY19AiZvmOdZ4X9YNoRoSlXppX6G1RV3ns0Ko6JsBjFgyMf46WMNPQyKubPictTbJ5mYoWqVAG34rR5WGV+fa1TxbB4cN3AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHfHB9nvMfvJuDFX6quGzEFKBvYISDI6PQoUsQXtKJTh15OoT/CoVk3oF2mIqphAv1robCFsujC/QeUKQvrSuDVq1j6Bd0dtvEL3baq33UQq1eDsF94aK/SBG7JFPC2tHr2WDhK57NQM0O4NMGvXA2Leq1f9KCoyWv1pxp9VDT5Gl7meKwWcTvHHyV4G1i0YPax/8cXEkPSOtK4ZRPOH2e3Vffa5pvDyCHCAMV3AUDiUc7u75A3ozSUtMFVuBn6PyV9O/LKPTf45h1zGdgPDH8DyqJSyr5Q2viB8AqjIg1rdSX9++QoDOjP4PxOaZ2waYbwEHI6H3qzw3yNgou2KOQM=</dsig:X509Certificate>
+            </dsig:X509Data>
+        </dsig:KeyInfo>
+    </dsig:Signature>
+    <samlp:Status>
+        <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
+    </samlp:Status>
+</samlp:LogoutResponse>
-- 
GitLab