1mod assertion;
71mod der;
72mod metadata;
73mod xml;
74
75#[cfg(any(fuzzing, test, feature = "test-support"))]
76#[doc(hidden)]
77pub fn xml_parse_for_tests(input: &str) -> Result<(), String> {
78 xml::parse(input).map(|_| ())
79}
80
81#[cfg(fuzzing)]
82#[doc(hidden)]
83pub fn xml_parse_for_fuzzing(input: &str) -> Result<(), String> {
84 xml::parse(input).map(|_| ())
85}
86
87#[cfg(test)]
88mod tests;
89
90use std::collections::HashMap;
91use std::sync::Arc;
92use std::time::{SystemTime, UNIX_EPOCH};
93
94use rand_core::{OsRng, RngCore};
95use serde::{Deserialize, Serialize};
96
97use crate::application::Application;
98use crate::body::form_urlencoded::FormUrlEncoded;
99use crate::core::New;
100use crate::extract::{FromRequest, Query};
101use crate::header::Header;
102use crate::middleware::Middleware;
103use crate::mime_type::MimeType;
104use crate::range::Range;
105use crate::request::{Request, METHOD};
106use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
107use crate::server::ConnectionInfo;
108use crate::session::{destroy_cookie, session_cookie, session_id_from_request, SessionStore};
109
110pub use metadata::SamlIdpMetadata;
111
112use metadata::decode_base64_flexible;
113
114const SESSION_COOKIE: &str = "_rws_saml_sid";
115const SESSION_TTL: u64 = 86_400;
116pub const CLAIMS_HEADER: &str = "X-Rws-Saml-Claims";
118const METADATA_PATH: &str = "/saml/metadata";
119const LOGIN_PATH: &str = "/saml/login";
120const ACS_PATH: &str = "/saml/acs";
121const LOGOUT_PATH: &str = "/saml/logout";
122
123#[derive(Clone, Default)]
136pub struct AttributeMap {
137 mappings: Vec<(String, String)>,
138}
139
140impl AttributeMap {
141 pub fn new() -> Self {
144 AttributeMap { mappings: Vec::new() }
145 }
146
147 pub fn map(mut self, saml_attribute_name: &str, field: &str) -> Self {
149 self.mappings.push((saml_attribute_name.to_string(), field.to_string()));
150 self
151 }
152
153 fn apply(&self, raw: &HashMap<String, String>) -> HashMap<String, String> {
154 let mut out = HashMap::new();
155 for (saml_name, field) in &self.mappings {
156 if let Some(v) = raw.get(saml_name) {
157 out.insert(field.clone(), v.clone());
158 }
159 }
160 out
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct SamlClaims {
168 pub name_id: String,
170 pub attributes: HashMap<String, String>,
172}
173
174pub struct SamlConfig {
176 pub sp_entity_id: String,
179 pub sp_acs_url: String,
183 pub idp_metadata: SamlIdpMetadata,
185 pub sessions: Arc<SessionStore>,
188}
189
190pub struct SamlSp {
192 config: Arc<SamlConfig>,
193 attribute_map: AttributeMap,
194 post_login_redirect: String,
195 login_path: String,
196 acs_path: String,
197 logout_path: String,
198 metadata_path: String,
199}
200
201impl SamlSp {
202 pub fn new(config: SamlConfig) -> Self {
205 SamlSp {
206 config: Arc::new(config),
207 attribute_map: AttributeMap::new(),
208 post_login_redirect: "/".to_string(),
209 login_path: LOGIN_PATH.to_string(),
210 acs_path: ACS_PATH.to_string(),
211 logout_path: LOGOUT_PATH.to_string(),
212 metadata_path: METADATA_PATH.to_string(),
213 }
214 }
215
216 pub fn attribute_map(mut self, map: AttributeMap) -> Self {
219 self.attribute_map = map;
220 self
221 }
222
223 pub fn post_login_redirect(mut self, path: &str) -> Self {
225 self.post_login_redirect = path.to_string();
226 self
227 }
228
229 pub fn login_path(mut self, path: &str) -> Self {
231 self.login_path = path.to_string();
232 self
233 }
234
235 pub fn acs_path(mut self, path: &str) -> Self {
237 self.acs_path = path.to_string();
238 self
239 }
240
241 pub fn logout_path(mut self, path: &str) -> Self {
243 self.logout_path = path.to_string();
244 self
245 }
246
247 pub fn metadata_path(mut self, path: &str) -> Self {
249 self.metadata_path = path.to_string();
250 self
251 }
252
253 pub fn claims(req: &Request) -> Option<SamlClaims> {
257 req.headers
258 .iter()
259 .find(|h| h.name.eq_ignore_ascii_case(CLAIMS_HEADER))
260 .and_then(|h| serde_json::from_str(&h.value).ok())
261 }
262
263 pub fn name_id(req: &Request) -> Option<String> {
265 Self::claims(req).map(|c| c.name_id)
266 }
267
268 pub fn attr(req: &Request, field: &str) -> Option<String> {
270 Self::claims(req).and_then(|c| c.attributes.get(field).cloned())
271 }
272
273 fn handle_metadata(&self) -> Response {
276 let xml = format!(
277 r#"<?xml version="1.0" encoding="UTF-8"?><EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}"><SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnabled="urn:oasis:names:tc:SAML:2.0:protocol"><AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{acs}" index="0" isDefault="true"/></SPSSODescriptor></EntityDescriptor>"#,
278 entity = xml_escape(&self.config.sp_entity_id),
279 acs = xml_escape(&self.config.sp_acs_url),
280 );
281 let mut r = Response::new();
282 r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
283 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
284 r.content_range_list = vec![Range::get_content_range(xml.into_bytes(), "application/samlmetadata+xml".to_string())];
285 r
286 }
287
288 fn handle_login(&self, request: &Request) -> Response {
289 let query = Query::from_request(request).map(|q| q.0).unwrap_or_default();
290 let return_to = query.get("return_to").cloned().unwrap_or_else(|| self.post_login_redirect.clone());
291
292 let request_id = format!("_{}", random_hex(16));
293 let authn_request_xml = format!(
294 r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{id}" Version="2.0" IssueInstant="{issued}" Destination="{dest}" AssertionConsumerServiceURL="{acs}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer>{issuer}</saml:Issuer></samlp:AuthnRequest>"#,
295 id = request_id,
296 issued = iso8601_now(),
297 dest = xml_escape(&self.config.idp_metadata.sso_url),
298 acs = xml_escape(&self.config.sp_acs_url),
299 issuer = xml_escape(&self.config.sp_entity_id),
300 );
301 let saml_request_b64 = base64_standard_encode(authn_request_xml.as_bytes());
302
303 let mut session = self.config.sessions.create();
304 session.set("_saml_request_id", &request_id);
305 session.set("_saml_return_to", &return_to);
306 self.config.sessions.save(&session);
307
308 let html = format!(
309 r#"<!doctype html><html><body onload="document.forms[0].submit()"><noscript><p>Click the button to continue.</p></noscript><form method="POST" action="{action}"><input type="hidden" name="SAMLRequest" value="{req}"/><noscript><button type="submit">Continue</button></noscript></form></body></html>"#,
310 action = xml_escape(&self.config.idp_metadata.sso_url),
311 req = xml_escape(&saml_request_b64),
312 );
313 let mut r = Response::new();
314 r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
315 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
316 r.content_range_list = vec![Range::get_content_range(html.into_bytes(), MimeType::TEXT_HTML.to_string())];
317 r.headers.push(Header { name: "Set-Cookie".into(), value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL).into() });
318 r
319 }
320
321 fn handle_acs(&self, request: &Request) -> Response {
322 let sid = match session_id_from_request(request, SESSION_COOKIE) {
323 Some(id) => id,
324 None => return Self::error_response("missing SP session cookie"),
325 };
326 let mut session = match self.config.sessions.load(&sid) {
327 Some(s) => s,
328 None => return Self::error_response("unknown or expired SP session"),
329 };
330 let request_id = session.get("_saml_request_id").map(|s| s.to_string());
331 let return_to = session.get("_saml_return_to").unwrap_or(&self.post_login_redirect).to_string();
332
333 let form = match FormUrlEncoded::parse(request.body.to_vec()) {
334 Ok(f) => f,
335 Err(e) => return Self::error_response(&format!("malformed ACS request body: {e}")),
336 };
337 let saml_response_b64 = match form.get("SAMLResponse") {
338 Some(v) => v,
339 None => return Self::error_response("missing SAMLResponse field"),
340 };
341 let raw_xml_bytes = match decode_base64_flexible(saml_response_b64) {
342 Ok(b) => b,
343 Err(e) => return Self::error_response(&e.0),
344 };
345 let raw_xml = match String::from_utf8(raw_xml_bytes) {
346 Ok(s) => s,
347 Err(_) => return Self::error_response("SAMLResponse is not valid UTF-8"),
348 };
349
350 let verified = assertion::parse_and_verify(
351 &raw_xml,
352 &self.config.idp_metadata.entity_id,
353 &self.config.idp_metadata.signing_key,
354 &self.config.sp_entity_id,
355 &self.config.sp_acs_url,
356 request_id.as_deref(),
357 unix_now(),
358 );
359 let verified = match verified {
360 Ok(v) => v,
361 Err(e) => return Self::error_response(&e.0),
362 };
363
364 let claims = SamlClaims {
365 name_id: verified.name_id,
366 attributes: self.attribute_map.apply(&verified.attributes),
367 };
368
369 session.remove("_saml_request_id");
370 session.remove("_saml_return_to");
371 let claims_json = serde_json::to_string(&claims).unwrap_or_default();
372 session.set("_saml_claims", &claims_json);
373 self.config.sessions.save(&session);
374
375 let mut r = Self::redirect(&return_to);
376 r.headers.push(Header { name: "Set-Cookie".into(), value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL).into() });
377 r
378 }
379
380 fn handle_logout(&self, request: &Request) -> Response {
381 if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
382 self.config.sessions.destroy(&sid);
383 }
384 let mut r = Self::redirect("/");
385 r.headers.push(Header { name: "Set-Cookie".into(), value: destroy_cookie(SESSION_COOKIE).into() });
386 r
387 }
388
389 fn redirect(url: &str) -> Response {
390 let mut r = Response::new();
391 r.status_code = *STATUS_CODE_REASON_PHRASE.n302_found.status_code;
392 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase.to_string();
393 r.headers.push(Header { name: "Location".into(), value: url.to_string().into() });
394 r
395 }
396
397 fn error_response(msg: &str) -> Response {
398 crate::error::problem::problem_with_text(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, msg, msg)
399 }
400}
401
402impl Middleware for SamlSp {
403 fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
404 let path = request.request_uri.split('?').next().unwrap_or("");
405 let is_get = request.method.eq_ignore_ascii_case(METHOD.get);
406 let is_post = request.method.eq_ignore_ascii_case(METHOD.post);
407
408 if path == self.metadata_path && is_get {
409 return Ok(self.handle_metadata());
410 }
411 if path == self.login_path && is_get {
412 return Ok(self.handle_login(request));
413 }
414 if path == self.acs_path && is_post {
415 return Ok(self.handle_acs(request));
416 }
417 if path == self.logout_path && is_get {
418 return Ok(self.handle_logout(request));
419 }
420
421 if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
422 if let Some(session) = self.config.sessions.load(&sid) {
423 if let Some(claims_json) = session.get("_saml_claims") {
424 let mut req = request.clone();
425 req.headers.push(Header { name: CLAIMS_HEADER.to_string().into(), value: claims_json.to_string().into() });
426 return match crate::authz::Identity::from_claims(claims_json, "name_id", "saml") {
427 Some(identity) => crate::authz::with_identity(identity, || next.execute(&req, connection)),
428 None => next.execute(&req, connection),
429 };
430 }
431 }
432 }
433
434 let return_to = crate::sso::client::url_encode(&request.request_uri);
435 Ok(Self::redirect(&format!("{}?return_to={}", self.login_path, return_to)))
436 }
437}
438
439fn unix_now() -> u64 {
440 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
441}
442
443fn iso8601_now() -> String {
444 format_iso8601(unix_now())
445}
446
447fn format_iso8601(total_secs: u64) -> String {
449 let days = total_secs / 86_400;
450 let secs_of_day = total_secs % 86_400;
451 let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
452 let (y, m, d) = days_to_ymd(days);
453 format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
454}
455
456fn days_to_ymd(days_since_epoch: u64) -> (u32, u32, u32) {
459 let z = days_since_epoch as i64 + 719_468;
460 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
461 let doe = (z - era * 146_097) as u64;
462 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
463 let y = yoe as i64 + era * 400;
464 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
465 let mp = (5 * doy + 2) / 153;
466 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
467 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
468 let y = if m <= 2 { y + 1 } else { y };
469 (y as u32, m, d)
470}
471
472fn random_hex(n_bytes: usize) -> String {
473 let mut bytes = vec![0u8; n_bytes];
474 OsRng.fill_bytes(&mut bytes);
475 bytes.iter().map(|b| format!("{:02x}", b)).collect()
476}
477
478const STD_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
479
480fn base64_standard_encode(bytes: &[u8]) -> String {
484 let mut out = String::with_capacity(((bytes.len() + 2) / 3) * 4);
485 let mut i = 0;
486 while i + 3 <= bytes.len() {
487 let (b0, b1, b2) = (bytes[i] as usize, bytes[i + 1] as usize, bytes[i + 2] as usize);
488 out.push(STD_TABLE[b0 >> 2] as char);
489 out.push(STD_TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
490 out.push(STD_TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
491 out.push(STD_TABLE[b2 & 0x3f] as char);
492 i += 3;
493 }
494 let rem = bytes.len() - i;
495 if rem == 1 {
496 let b0 = bytes[i] as usize;
497 out.push(STD_TABLE[b0 >> 2] as char);
498 out.push(STD_TABLE[(b0 & 3) << 4] as char);
499 out.push_str("==");
500 } else if rem == 2 {
501 let (b0, b1) = (bytes[i] as usize, bytes[i + 1] as usize);
502 out.push(STD_TABLE[b0 >> 2] as char);
503 out.push(STD_TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
504 out.push(STD_TABLE[(b1 & 0xf) << 2] as char);
505 out.push('=');
506 }
507 out
508}
509
510fn xml_escape(s: &str) -> String {
513 let mut out = String::with_capacity(s.len());
514 for c in s.chars() {
515 match c {
516 '&' => out.push_str("&"),
517 '<' => out.push_str("<"),
518 '>' => out.push_str(">"),
519 '"' => out.push_str("""),
520 '\'' => out.push_str("'"),
521 _ => out.push(c),
522 }
523 }
524 out
525}