alibi_plugins/sso/
saml_logout.rs1use 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 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}