Skip to main content

alibi_plugins/sso/
saml_logout.rs

1//! Start SP-initiated logout from the real authenticated SAML session.
2use super::{
3    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, JsValue, ServerEndpoint,
4    SsoPlugin, Value, json,
5};
6use alibi_core::{AuthUser, session::SessionRequest};
7use base64::Engine as _;
8use quick_xml::{
9    Writer,
10    events::{BytesEnd, BytesStart, BytesText, Event},
11};
12use std::io::Write as _;
13impl SsoPlugin {
14    pub fn initiate_slo_endpoint(
15        provider_id: &str,
16        body: &Value,
17    ) -> AuthResult<ServerEndpoint<Value>> {
18        ServerEndpoint::new("sso", "initiateSLO")
19            .with_params(&json!({"providerId":provider_id}))?
20            .with_body(body)
21    }
22    pub(super) async fn initiate_logout<S: AuthSchema>(
23        &self,
24        provider_id: &str,
25        _body: &JsValue,
26        request: &impl SessionRequest,
27        ctx: &AuthContext<S>,
28    ) -> AuthResult<EndpointResponse> {
29        let (user, session) = crate::helpers::ordinary_session(request, ctx).await?;
30        if !self.config.saml_enable_single_logout {
31            return Err(error(
32                400,
33                "SINGLE_LOGOUT_NOT_ENABLED",
34                "Single Logout is not enabled",
35            ));
36        }
37        let provider = self
38            .find_provider(provider_id, ctx)
39            .await?
40            .ok_or_else(|| error(404, "SAML_PROVIDER_NOT_FOUND", "SAML provider not found"))?;
41        let config = provider
42            .saml_config
43            .as_deref()
44            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
45            .filter(|value| !value.is_null())
46            .ok_or_else(|| error(404, "SAML_PROVIDER_NOT_FOUND", "SAML provider not found"))?;
47        let config = super::idp_metadata_xml::effective(&config)?;
48        let metadata = super::sp_metadata_xml::from_config(&config)?;
49        let services = config
50            .get("idpMetadata")
51            .and_then(|metadata| metadata.get("singleLogoutService"))
52            .and_then(Value::as_array)
53            .filter(|services| !services.is_empty())
54            .ok_or_else(|| {
55                error(
56                    400,
57                    "IDP_SLO_NOT_SUPPORTED",
58                    "IdP does not support Single Logout Service",
59                )
60            })?;
61        let destination = services
62            .iter()
63            .find(|service| {
64                service.get("Binding").and_then(Value::as_str)
65                    == Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect")
66            })
67            .and_then(|service| service.get("Location"))
68            .and_then(Value::as_str)
69            .ok_or_else(|| AuthError::internal("ERR_NO_REDIRECT_SLO_ENDPOINT"))?;
70        let lookup = format!("saml-session-by-id:{}", session.id);
71        let stored_lookup = ctx.verifications().find(&lookup).await?;
72        let key = stored_lookup
73            .as_ref()
74            .map(|row| row.value())
75            .transpose()?
76            .map(str::to_owned);
77        let stored = if let Some(key) = key.as_deref() {
78            ctx.verifications().find(key).await?
79        } else {
80            None
81        };
82        let data = stored
83            .as_ref()
84            .and_then(|row| row.value().ok())
85            .and_then(|value| serde_json::from_str::<Value>(value).ok());
86        let name = data
87            .as_ref()
88            .and_then(|data| data.get("nameID"))
89            .and_then(Value::as_str)
90            .filter(|name| !name.is_empty())
91            .or_else(|| user.email())
92            .unwrap_or_default();
93        let index = data
94            .as_ref()
95            .and_then(|data| data.get("sessionIndex"))
96            .and_then(Value::as_str);
97        let entity = metadata
98            .as_ref()
99            .map(|sp| sp.entity_id.as_str())
100            .or_else(|| {
101                config
102                    .get("spMetadata")
103                    .and_then(|metadata| metadata.get("entityID"))
104                    .and_then(Value::as_str)
105                    .filter(|id| !id.is_empty())
106                    .or_else(|| config.get("issuer").and_then(Value::as_str))
107            })
108            .unwrap_or(&provider.issuer);
109        let format = metadata
110            .as_ref()
111            .and_then(|sp| sp.name_id_formats.first().map(String::as_str))
112            .or_else(|| {
113                config
114                    .get("identifierFormat")
115                    .and_then(Value::as_str)
116                    .filter(|format| !format.is_empty())
117            })
118            .unwrap_or("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
119        let id = format!("_{}", uuid::Uuid::new_v4());
120        let instant = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
121        let mut writer = Writer::new(Vec::new());
122        let mut root = BytesStart::new("samlp:LogoutRequest");
123        _ = root.extend_attributes([
124            ("xmlns:samlp", "urn:oasis:names:tc:SAML:2.0:protocol"),
125            ("xmlns:saml", "urn:oasis:names:tc:SAML:2.0:assertion"),
126            ("ID", id.as_str()),
127            ("Version", "2.0"),
128            ("IssueInstant", instant.as_str()),
129            ("Destination", destination),
130        ]);
131        let mut name_id = BytesStart::new("saml:NameID");
132        name_id.push_attribute(("Format", format));
133        for event in [
134            Event::Start(root),
135            Event::Start(BytesStart::new("saml:Issuer")),
136            Event::Text(BytesText::new(entity)),
137            Event::End(BytesEnd::new("saml:Issuer")),
138            Event::Start(name_id),
139            Event::Text(BytesText::new(name)),
140            Event::End(BytesEnd::new("saml:NameID")),
141        ] {
142            writer
143                .write_event(event)
144                .map_err(|error| AuthError::internal(error.to_string()))?;
145        }
146        if let Some(index) = index {
147            for event in [
148                Event::Start(BytesStart::new("samlp:SessionIndex")),
149                Event::Text(BytesText::new(index)),
150                Event::End(BytesEnd::new("samlp:SessionIndex")),
151            ] {
152                writer
153                    .write_event(event)
154                    .map_err(|error| AuthError::internal(error.to_string()))?;
155            }
156        }
157        writer
158            .write_event(Event::End(BytesEnd::new("samlp:LogoutRequest")))
159            .map_err(|error| AuthError::internal(error.to_string()))?;
160        let mut encoder =
161            flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
162        encoder
163            .write_all(&writer.into_inner())
164            .map_err(|error| AuthError::internal(error.to_string()))?;
165        let encoded = base64::engine::general_purpose::STANDARD.encode(
166            encoder
167                .finish()
168                .map_err(|error| AuthError::internal(error.to_string()))?,
169        );
170        // The pinned TS plugin puts callbackURL in the user argument, which samlify ignores.
171        let query = {
172            let mut query = url::form_urlencoded::Serializer::new(String::new());
173            _ = query.append_pair("SAMLRequest", &encoded);
174            query.finish()
175        };
176        let separator = if url::Url::parse(destination)
177            .map_err(|error| AuthError::internal(error.to_string()))?
178            .query()
179            .is_some_and(|query| !query.is_empty())
180        {
181            "&"
182        } else {
183            "?"
184        };
185        let location = format!("{destination}{separator}{query}");
186        let expires_at = chrono::Utc::now()
187            .checked_add_signed(self.config.saml_logout_request_ttl)
188            .ok_or_else(|| AuthError::internal("Logout deadline overflow"))?;
189        _ = ctx
190            .verifications()
191            .create(alibi_core::CreateVerification {
192                identifier: format!("saml-logout-request:{id}"),
193                value: provider_id.into(),
194                expires_at,
195            })
196            .await?;
197        if let Some(key) = key
198            && let Err(error) = ctx.verifications().delete(&key).await
199        {
200            tracing::warn!(%error,"Failed to delete SAML session key during logout");
201        }
202        if let Err(error) = ctx.verifications().delete(&lookup).await {
203            tracing::warn!(%error,"Failed to delete SAML session lookup during logout");
204        }
205        ctx.database.delete_session(&session.token).await?;
206        let mut response = crate::authentication_helpers::redirect(&location);
207        for cookie in crate::helpers::delete_session_cookie_headers(&ctx.config)? {
208            response.headers.append("Set-Cookie", cookie);
209        }
210        Ok(EndpointResponse::from_http_response(response))
211    }
212}
213fn error(status: u16, code: &str, message: &str) -> AuthError {
214    AuthError::Api {
215        status,
216        code: Some(code.into()),
217        message: message.into(),
218    }
219}
220pub(super) fn validate(input: Option<&JsValue>) -> AuthResult<JsValue> {
221    crate::endpoint::validate_fields(
222        input,
223        "body",
224        &[crate::authentication_helpers::JsonField::string(
225            "callbackURL",
226            false,
227        )],
228    )
229}