diff --git a/.lsp/config.edn b/.lsp/config.edn index 42316090154500fa46a1a9a49c8f17261f3b1c98..0e767070d94db67a342466a0c2a96fc7bd5b8ebe 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 270b316f53d39335cff230f07bfa56271acaa7a5..16674fde416f5fab6802496ed67889a06fd6778a 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 aca1e9964724fcd50a8c82782db38690418a5020..51decb6a701ac867d27ebdab19c2e0b2e9458627 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 da1cea01f412ff98a16222698cd8e75206aee4d0..19154e571e9071019ba5c435ce5ed9dd54480840 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 c80d1b8ea747831844849a2ff9b47c35c16a3ba1..26a829846e17cf08b22ee20d987a24c572022a8d 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 50abb67e678eb8ed9a334902542a831483f5b633..00ac09ef1b2f81c03debccb4f78346f5734628ca 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 fc1d4cc318a5b4e48a3f28ead022f89ea27cadbc..5c0c9fa5b34cb44e6399a58b438551044042084e 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 cf9bbce18b9108fa91740e0d222db8abe03b4541..9dd73a8bedd2fa75576bfc7fdf83b7106ecc7cc5 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 9e1e18718b604761894b8534353665ee4498134a..e629b480dace33e71f5d9b08a15308e9180827fc 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 f8d712de1babc04594761ac3d6b5fb56dcc4b5a0..ff0e171e2fe5a413aca20dbdb56a2ee3f9d7f02b 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 1654f311eb3d668631f139875650c9dafa15acf9..ed2f82dd98d29db656784eb01d327fb4bbb38bf7 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 658766457c17271a465731ac447d65698b44fe5f..c196f790d1d3bb29736553c14c5f01b6040dcc9d 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 43f71aa63d4ca7c2c0371524a9b32c65dabf18b7..083f369eecc8d82ae6fb914110d2652d4e6ba2b8 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 23a95af4332ee5b37c689532c2032b474c4b92c2..71519f449e1acdc76678247459844fc0d6ac9942 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 b0f360c441e486fcb2998cb6bd76bf092834e075..efceff94243d678b61f585bb59c100825a925e91 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 265e9dfa1d1d475fa8ce984ab01740f2b7c01ea7..0e6bd4184c29e3a612eb9275d5124dbc83b42cb8 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 0000000000000000000000000000000000000000..1b27612333c9b2dd07e92058d3c78bf16dac6e1c --- /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>