1use 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 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('&', "&")
373 .replace('<', "<")
374 .replace('>', ">")
375 .replace('"', """)
376 .replace('\'', "'")
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}