Skip to main content

alibi_plugins/sso/
saml_slo.rs

1//! SAML logout callbacks with configured `IdP` authority and real session retirement.
2use super::{
3    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, JsValue, ServerEndpoint,
4    SsoPlugin, Value, json,
5};
6use alibi_core::session::SessionRequest;
7use base64::Engine as _;
8use bergshamra::xml::{Document, NodeId};
9use std::io::{Read as _, Write as _};
10const PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
11const ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
12impl SsoPlugin {
13    pub fn slo_endpoint(provider_id: &str, body: &Value) -> AuthResult<ServerEndpoint<Value>> {
14        ServerEndpoint::new("sso", "sloEndpoint")
15            .with_params(&json!({"providerId":provider_id}))?
16            .with_body(body)
17    }
18    pub(super) async fn slo<S: AuthSchema>(
19        &self,
20        provider_id: &str,
21        body: Option<&Value>,
22        query: Option<&Value>,
23        request: &impl SessionRequest,
24        ctx: &AuthContext<S>,
25    ) -> AuthResult<EndpointResponse> {
26        if !self.config.saml_enable_single_logout {
27            return Err(error(
28                400,
29                "SINGLE_LOGOUT_NOT_ENABLED",
30                "Single Logout is not enabled",
31            ));
32        }
33        let string = |name: &str| {
34            body.and_then(|body| body.get(name))
35                .and_then(Value::as_str)
36                .filter(|value| !value.is_empty())
37                .or_else(|| {
38                    query
39                        .and_then(|query| query.get(name))
40                        .and_then(Value::as_str)
41                        .filter(|value| !value.is_empty())
42                })
43        };
44        let response = string("SAMLResponse");
45        let request_data = string("SAMLRequest");
46        let relay = string("RelayState");
47        let path = format!(
48            "{}/sso/saml2/sp/slo/{provider_id}",
49            ctx.config.base_url.trim_end_matches('/')
50        );
51        let target = super::saml_acs::safe_redirect([relay], &path, ctx);
52        if response.is_none() && request_data.is_none() {
53            let mut url =
54                url::Url::parse(&target).map_err(|error| AuthError::internal(error.to_string()))?;
55            _ = url
56                .query_pairs_mut()
57                .append_pair("error", "invalid_request")
58                .append_pair("error_description", "missing_logout_data");
59            return Ok(EndpointResponse::from_http_response(
60                crate::authentication_helpers::redirect(url.as_str()),
61            ));
62        }
63        let provider = self
64            .find_provider(provider_id, ctx)
65            .await?
66            .ok_or_else(|| error(404, "SAML_PROVIDER_NOT_FOUND", "SAML provider not found"))?;
67        let config = provider
68            .saml_config
69            .as_deref()
70            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
71            .filter(|config| !config.is_null())
72            .ok_or_else(|| error(404, "SAML_PROVIDER_NOT_FOUND", "SAML provider not found"))?;
73        let config = super::idp_metadata_xml::effective(&config)?;
74        let metadata = super::sp_metadata_xml::from_config(&config)?;
75        let is_response = response.is_some();
76        let kind = if is_response {
77            "LogoutResponse"
78        } else {
79            "LogoutRequest"
80        };
81        let invalid = || {
82            error(
83                400,
84                if is_response {
85                    "INVALID_LOGOUT_RESPONSE"
86                } else {
87                    "INVALID_LOGOUT_REQUEST"
88                },
89                if is_response {
90                    "Invalid LogoutResponse"
91                } else {
92                    "Invalid LogoutRequest"
93                },
94            )
95        };
96        let encoded = response.or(request_data).ok_or_else(invalid)?;
97        let raw = base64::engine::general_purpose::STANDARD
98            .decode(encoded)
99            .map_err(|_| invalid())?;
100        let post = *request.session_method() == alibi_core::HttpMethod::Post
101            && body
102                .and_then(|body| {
103                    body.get(if is_response {
104                        "SAMLResponse"
105                    } else {
106                        "SAMLRequest"
107                    })
108                })
109                .and_then(Value::as_str)
110                .is_some_and(|value| !value.is_empty());
111        let xml = if post {
112            String::from_utf8(raw).map_err(|_| invalid())?
113        } else {
114            let mut xml = String::new();
115            _ = flate2::read::DeflateDecoder::new(raw.as_slice())
116                .take(262_145)
117                .read_to_string(&mut xml)
118                .map_err(|_| invalid())?;
119            xml
120        };
121        if xml.len() > 262_144 || xml.contains("<!DOCTYPE") || xml.contains("<!ENTITY") {
122            return Err(invalid());
123        }
124        let doc = bergshamra::xml::uppsala::parse(&xml).map_err(|_| invalid())?;
125        let root = doc.document_element().ok_or_else(invalid)?;
126        if !doc
127            .element(root)
128            .is_some_and(|element| element.name.matches(Some(PROTOCOL), kind))
129        {
130            return Err(invalid());
131        }
132        let issuer = children(&doc, root, ASSERTION, "Issuer")
133            .first()
134            .map(|id| doc.text_content_deep(*id));
135        let expected = config
136            .get("idpMetadata")
137            .and_then(|metadata| metadata.get("entityID"))
138            .and_then(Value::as_str)
139            .ok_or_else(invalid)?;
140        if issuer.as_deref() != Some(expected) {
141            return Err(invalid());
142        }
143        let signatures = children(
144            &doc,
145            root,
146            "http://www.w3.org/2000/09/xmldsig#",
147            "Signature",
148        );
149        let required = if is_response {
150            self.config.saml_want_logout_response_signed
151        } else {
152            self.config.saml_want_logout_request_signed
153        };
154        // A Redirect message needs a detached signature over the original query.
155        // This callback, like the reference, cannot substitute an XML signature.
156        if !post && required {
157            return Err(invalid());
158        }
159        if required {
160            if signatures.is_empty() {
161                return Err(invalid());
162            }
163            verify_signature(&doc, &xml, root, &config).map_err(|()| invalid())?;
164        }
165        if is_response {
166            let status = children(&doc, root, PROTOCOL, "Status")
167                .first()
168                .and_then(|node| {
169                    children(&doc, *node, PROTOCOL, "StatusCode")
170                        .first()
171                        .copied()
172                })
173                .and_then(|node| doc.get_attribute(node, "Value"));
174            if status.is_some_and(|status| status != "urn:oasis:names:tc:SAML:2.0:status:Success") {
175                return Err(error(400, "LOGOUT_FAILED_AT_IDP", "Logout failed at IdP"));
176            }
177            if let Some(id) = doc.get_attribute(root, "InResponseTo")
178                && let Err(error) = ctx
179                    .verifications()
180                    .delete(&format!("saml-logout-request:{id}"))
181                    .await
182            {
183                tracing::warn!(%error,"Failed to delete logout request proof");
184            }
185            return cleared(crate::authentication_helpers::redirect(&target), ctx);
186        }
187        let name = children(&doc, root, ASSERTION, "NameID")
188            .first()
189            .map(|id| doc.text_content_deep(*id))
190            .filter(|name| !name.is_empty())
191            .ok_or_else(invalid)?;
192        let session_index = children(&doc, root, PROTOCOL, "SessionIndex")
193            .first()
194            .map(|id| doc.text_content_deep(*id));
195        let key = format!("saml-session:{provider_id}:{name}");
196        let stored = ctx.verifications().find(&key).await?;
197        if let Some(stored) = stored {
198            let data = stored
199                .value()
200                .ok()
201                .and_then(|value| serde_json::from_str::<Value>(value).ok());
202            if let Some(data) = data {
203                let index = data.get("sessionIndex").and_then(Value::as_str);
204                if session_index.as_deref().is_none_or(str::is_empty)
205                    || index.is_none_or(str::is_empty)
206                    || session_index.as_deref() == index
207                {
208                    if let Some(token) = data.get("sessionToken").and_then(Value::as_str)
209                        && let Err(error) = ctx.database.delete_session(token).await
210                    {
211                        tracing::warn!(%error,"Failed to delete SAML session during SLO");
212                    }
213                    if let Some(id) = data.get("sessionId").and_then(Value::as_str)
214                        && let Err(error) = ctx
215                            .verifications()
216                            .delete(&format!("saml-session-by-id:{id}"))
217                            .await
218                    {
219                        tracing::warn!(%error,"Failed to delete SAML session lookup during SLO");
220                    }
221                }
222            }
223            if let Err(error) = ctx.verifications().delete(&key).await {
224                tracing::warn!(%error,"Failed to delete SAML session key during SLO");
225            }
226        }
227        if let Ok((_, session)) = crate::helpers::ordinary_session(request, ctx).await {
228            ctx.database.delete_session(&session.token).await?;
229        }
230        let services = config
231            .get("idpMetadata")
232            .and_then(|metadata| metadata.get("singleLogoutService"))
233            .and_then(Value::as_array)
234            .ok_or_else(invalid)?;
235        let binding = if post {
236            "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
237        } else {
238            "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
239        };
240        let destination = services
241            .iter()
242            .find(|service| service.get("Binding").and_then(Value::as_str) == Some(binding))
243            .and_then(|service| service.get("Location"))
244            .and_then(Value::as_str)
245            .ok_or_else(invalid)?;
246        let entity = metadata
247            .as_ref()
248            .map(|sp| sp.entity_id.as_str())
249            .or_else(|| {
250                config
251                    .get("spMetadata")
252                    .and_then(|metadata| metadata.get("entityID"))
253                    .and_then(Value::as_str)
254                    .filter(|entity| !entity.is_empty())
255                    .or_else(|| config.get("issuer").and_then(Value::as_str))
256            })
257            .unwrap_or(&provider.issuer);
258        let id = doc.get_attribute(root, "ID").ok_or_else(invalid)?;
259        let response = logout_response(entity, destination, id);
260        if post {
261            if !url::Url::parse(destination)
262                .is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
263            {
264                return Err(super::api(
265                    400,
266                    "SAML POST binding location must be an absolute http or https URL",
267                ));
268            }
269            let relay = relay
270                .filter(|value| !value.is_empty())
271                .map_or_else(String::new, |value| {
272                    format!(
273                        r#"<input type="hidden" name="RelayState" value="{}" />"#,
274                        html_escape(value)
275                    )
276                });
277            let html = format!(
278                r#"<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="{}"><input type="hidden" name="SAMLResponse" value="{}" />{}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>"#,
279                html_escape(destination),
280                base64::engine::general_purpose::STANDARD.encode(response),
281                relay
282            );
283            return cleared(
284                alibi_core::AuthResponse::text(200, &html).with_header("content-type", "text/html"),
285                ctx,
286            );
287        }
288        let mut encoder =
289            flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
290        encoder
291            .write_all(response.as_bytes())
292            .map_err(|error| AuthError::internal(error.to_string()))?;
293        let encoded = base64::engine::general_purpose::STANDARD.encode(
294            encoder
295                .finish()
296                .map_err(|error| AuthError::internal(error.to_string()))?,
297        );
298        let query = {
299            let mut query = url::form_urlencoded::Serializer::new(String::new());
300            _ = query.append_pair("SAMLResponse", &encoded);
301            if let Some(relay) = relay {
302                _ = query.append_pair("RelayState", relay);
303            }
304            query.finish()
305        };
306        cleared(
307            crate::authentication_helpers::redirect(&format!("{destination}?{query}")),
308            ctx,
309        )
310    }
311}
312fn children(doc: &Document<'_>, parent: NodeId, namespace: &str, name: &str) -> Vec<NodeId> {
313    doc.children(parent)
314        .into_iter()
315        .filter(|id| {
316            doc.element(*id)
317                .is_some_and(|element| element.name.matches(Some(namespace), name))
318        })
319        .collect()
320}
321fn verify_signature(doc: &Document<'_>, xml: &str, root: NodeId, config: &Value) -> Result<(), ()> {
322    super::saml_crypto::validate_signature_references(doc, root).map_err(|_| ())?;
323    let certificates = config
324        .get("idpMetadata")
325        .and_then(|metadata| metadata.get("cert"))
326        .or_else(|| config.get("cert"))
327        .ok_or(())?;
328    let certificates = if let Some(cert) = certificates.as_str() {
329        vec![cert]
330    } else {
331        certificates
332            .as_array()
333            .ok_or(())?
334            .iter()
335            .filter_map(Value::as_str)
336            .collect()
337    };
338    for cert in certificates {
339        let key = bergshamra::keys::loader::load_x509_cert_pem(cert.as_bytes()).map_err(|_| ())?;
340        let mut manager = bergshamra::KeysManager::new();
341        manager.add_key(key);
342        let context = bergshamra::DsigContext::new(manager);
343        let result =
344            bergshamra::dsig::verify::verify_all_document_with_source(&context, doc, Some(xml))
345                .map_err(|_| ())?;
346        if result.iter().all(bergshamra::VerifyResult::all_reference_digests_verified)&&result.iter().any(|result|matches!(result,bergshamra::VerifyResult::Valid{signature_node,references,..}if doc.parent(*signature_node)==Some(root)&&references.iter().any(|reference|reference.resolved_node==Some(root)&&reference.digest_verified))){return Ok(());}
347    }
348    Err(())
349}
350fn logout_response(entity: &str, destination: &str, response_to: &str) -> String {
351    let esc = |value: &str| quick_xml::escape::escape(value).into_owned();
352    format!(
353        r#"<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_{}" Version="2.0" IssueInstant="{}" Destination="{}" InResponseTo="{}"><saml:Issuer>{}</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status></samlp:LogoutResponse>"#,
354        uuid::Uuid::new_v4(),
355        chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
356        esc(destination),
357        esc(response_to),
358        esc(entity)
359    )
360}
361fn cleared<S: AuthSchema>(
362    mut response: alibi_core::AuthResponse,
363    ctx: &AuthContext<S>,
364) -> AuthResult<EndpointResponse> {
365    for cookie in crate::helpers::delete_session_cookie_headers(&ctx.config)? {
366        response.headers.append("Set-Cookie", cookie);
367    }
368    Ok(EndpointResponse::from_http_response(response))
369}
370fn html_escape(value: &str) -> String {
371    value
372        .replace('&', "&amp;")
373        .replace('<', "&lt;")
374        .replace('>', "&gt;")
375        .replace('"', "&quot;")
376        .replace('\'', "&#39;")
377}
378fn error(status: u16, code: &str, message: &str) -> AuthError {
379    AuthError::Api {
380        status,
381        code: Some(code.into()),
382        message: message.into(),
383    }
384}
385pub(super) fn validate(input: Option<&JsValue>, location: &str) -> AuthResult<Option<JsValue>> {
386    input
387        .map(|input| {
388            crate::endpoint::validate_fields(
389                Some(input),
390                location,
391                &[
392                    crate::authentication_helpers::JsonField::string("SAMLRequest", false),
393                    crate::authentication_helpers::JsonField::string("SAMLResponse", false),
394                    crate::authentication_helpers::JsonField::string("RelayState", false),
395                    crate::authentication_helpers::JsonField::string("SigAlg", false),
396                    crate::authentication_helpers::JsonField::string("Signature", false),
397                ],
398            )
399        })
400        .transpose()
401}