Skip to main content

saml_rs/api/
slo.rs

1use crate::browser::{BrowserInput, LogoutBinding, Outbound, PendingLogoutRequest, Started};
2use crate::config::{EntityId, IdpDescriptor, SpDescriptor};
3use crate::constants::Binding;
4use crate::entity::{capture_idp_issuance_window, now_iso8601, EntitySetting};
5use crate::error::SamlError as Error;
6use crate::flow::HttpRequest;
7use crate::logout::{
8    create_logout_request_with_session_indexes, create_logout_response, parse_logout_request_at,
9    parse_logout_response_at, LogoutRequestSessionIndexes, LogoutRequestValidation,
10};
11use crate::metadata::Metadata;
12use crate::model::{
13    LogoutCompleted, LogoutRequest, LogoutResponse, LogoutSubject, Received, ReplayKey,
14    SamlInstant, SamlValidationContext,
15};
16
17use super::raw_mapping::{
18    ensure_entity_id, ensure_relay_state, input_binding, raw_idp_descriptor, raw_sp_descriptor,
19    relay_state_from_input,
20};
21use super::{Idp, LogoutSigning, RespondSlo, Saml, SamlError, Sp, StartSlo};
22
23impl Saml<Sp> {
24    /// Start SP-initiated Single Logout.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`SamlError`] when relay state is invalid, IdP metadata cannot
29    /// be parsed, a compatible logout endpoint or signing key is missing, the
30    /// selected binding is unsupported, or logout request creation fails.
31    ///
32    /// # Examples
33    ///
34    /// ```no_run
35    /// use saml_rs::{IdpDescriptor, LogoutSubject, Saml, StartSlo};
36    ///
37    /// # fn logout(
38    /// #     sp: &Saml<saml_rs::Sp>,
39    /// #     idp: &IdpDescriptor,
40    /// #     subject: LogoutSubject,
41    /// # ) -> Result<(), saml_rs::SamlError> {
42    /// let started = sp.start_slo(idp, subject, StartSlo::post())?;
43    /// let form = started.outbound.post_form()?;
44    /// let snapshot = started.pending.snapshot();
45    /// # let _ = (form, snapshot);
46    /// # Ok(()) }
47    /// ```
48    pub fn start_slo(
49        &self,
50        idp: &IdpDescriptor,
51        subject: LogoutSubject,
52        options: StartSlo,
53    ) -> Result<Started<LogoutRequest>, SamlError> {
54        let raw_idp = raw_idp_descriptor(idp)?;
55        start_slo_impl(
56            &self.raw_service_provider().setting,
57            &self.raw_service_provider().metadata,
58            idp.entity_id(),
59            &raw_idp.metadata,
60            subject,
61            options,
62            StartSloRole::SessionParticipant,
63        )
64    }
65
66    /// Receive an IdP LogoutRequest.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`SamlError`] when the browser input or relay state is invalid,
71    /// the binding is unsupported for logout, IdP metadata cannot be parsed,
72    /// XML parsing or signature/trust validation fails, required
73    /// `IssueInstant` or optional `NotOnOrAfter` is not conformant,
74    /// `NotOnOrAfter` has expired under saml-rs' fail-closed policy, the
75    /// destination does not match local metadata, or replay validation detects
76    /// a duplicate or unusable expiration.
77    pub fn receive_slo(
78        &self,
79        idp: &IdpDescriptor,
80        input: BrowserInput<LogoutRequest>,
81        validation: SamlValidationContext<'_>,
82    ) -> Result<Received<LogoutRequest>, SamlError> {
83        let raw_idp = raw_idp_descriptor(idp)?;
84        receive_slo_impl(
85            &self.raw_service_provider().setting,
86            &self.raw_service_provider().metadata,
87            &raw_idp.metadata,
88            input,
89            validation,
90        )
91    }
92
93    /// Respond to a received IdP LogoutRequest.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`SamlError`] when IdP metadata cannot be parsed, relay state is
98    /// invalid, a compatible logout endpoint or signing key is missing, the
99    /// selected binding is unsupported, or logout response creation fails.
100    pub fn respond_slo(
101        &self,
102        idp: &IdpDescriptor,
103        request: &Received<LogoutRequest>,
104        options: RespondSlo,
105    ) -> Result<Outbound<LogoutResponse>, SamlError> {
106        let raw_idp = raw_idp_descriptor(idp)?;
107        respond_slo_impl(
108            &self.raw_service_provider().setting,
109            &self.raw_service_provider().metadata,
110            &raw_idp.metadata,
111            request,
112            options,
113        )
114    }
115
116    /// Finish SP-initiated Single Logout using stored pending LogoutRequest state.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`SamlError`] when the response does not match the pending
121    /// request, including issuer, binding, relay state, destination, or
122    /// `InResponseTo` mismatches; when IdP metadata cannot be parsed; when XML,
123    /// signature, trust, status, or time validation fails; or when replay
124    /// validation detects a duplicate or expired message.
125    ///
126    /// # Examples
127    ///
128    /// ```no_run
129    /// use saml_rs::{
130    ///     BrowserInput, FormField, IdpDescriptor, LogoutResponse, PendingLogoutRequest,
131    ///     ReplayPolicy, Saml, SamlValidationContext,
132    /// };
133    /// use std::time::SystemTime;
134    ///
135    /// # fn finish(
136    /// #     sp: &Saml<saml_rs::Sp>,
137    /// #     idp: &IdpDescriptor,
138    /// #     pending: &PendingLogoutRequest,
139    /// #     fields: Vec<FormField>,
140    /// # ) -> Result<(), saml_rs::SamlError> {
141    /// let validation = SamlValidationContext::new(
142    ///     SystemTime::now(),
143    ///     ReplayPolicy::DisabledForCompatibility,
144    /// );
145    /// let completed = sp.finish_slo(
146    ///     idp,
147    ///     pending,
148    ///     BrowserInput::<LogoutResponse>::post(fields),
149    ///     validation,
150    /// )?;
151    ///
152    /// let peer = completed.peer_entity_id().as_str();
153    /// # let _ = peer;
154    /// # Ok(()) }
155    /// ```
156    pub fn finish_slo(
157        &self,
158        idp: &IdpDescriptor,
159        pending: &PendingLogoutRequest,
160        input: BrowserInput<LogoutResponse>,
161        validation: SamlValidationContext<'_>,
162    ) -> Result<LogoutCompleted, SamlError> {
163        let raw_idp = raw_idp_descriptor(idp)?;
164        finish_slo_impl(
165            &self.raw_service_provider().setting,
166            &self.raw_service_provider().metadata,
167            idp.entity_id(),
168            &raw_idp.metadata,
169            pending,
170            input,
171            validation,
172        )
173    }
174}
175
176impl Saml<Idp> {
177    /// Start Session Authority Single Logout.
178    ///
179    /// The generated `LogoutRequest` always carries a UTC `NotOnOrAfter`
180    /// derived from the configured [`crate::IdpConfig::issuance_lifetime`] and
181    /// the same captured `IssueInstant`.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`SamlError`] when relay state is invalid, SP metadata cannot be
186    /// parsed, a compatible logout endpoint or signing key is missing, the
187    /// selected binding is unsupported, logout request creation fails, or the
188    /// configured issuance lifetime cannot be added to the current issue
189    /// instant.
190    ///
191    /// # Examples
192    ///
193    /// ```no_run
194    /// use saml_rs::{LogoutSubject, Saml, SpDescriptor, StartSlo};
195    ///
196    /// # fn logout(
197    /// #     idp: &Saml<saml_rs::Idp>,
198    /// #     sp: &SpDescriptor,
199    /// #     subject: LogoutSubject,
200    /// # ) -> Result<(), saml_rs::SamlError> {
201    /// let started = idp.start_slo(sp, subject, StartSlo::post())?;
202    /// let form = started.outbound.post_form()?;
203    /// let snapshot = started.pending.snapshot();
204    /// # let _ = (form, snapshot);
205    /// # Ok(()) }
206    /// ```
207    pub fn start_slo(
208        &self,
209        sp: &SpDescriptor,
210        subject: LogoutSubject,
211        options: StartSlo,
212    ) -> Result<Started<LogoutRequest>, SamlError> {
213        let raw_sp = raw_sp_descriptor(sp)?;
214        start_slo_impl(
215            &self.raw_identity_provider().setting,
216            &self.raw_identity_provider().metadata,
217            sp.entity_id(),
218            &raw_sp.metadata,
219            subject,
220            options,
221            StartSloRole::SessionAuthority {
222                issuance_lifetime: self.0.issuance_lifetime,
223            },
224        )
225    }
226
227    /// Receive an SP LogoutRequest.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`SamlError`] when the browser input or relay state is invalid,
232    /// the binding is unsupported for logout, SP metadata cannot be parsed, XML
233    /// parsing or signature/trust validation fails, required `IssueInstant` or
234    /// optional `NotOnOrAfter` is not conformant, `NotOnOrAfter` has expired
235    /// under saml-rs' fail-closed policy, the destination does not match local
236    /// metadata, or replay validation detects a duplicate or unusable
237    /// expiration.
238    ///
239    /// # Examples
240    ///
241    /// ```no_run
242    /// use saml_rs::{
243    ///     BrowserInput, FormField, LogoutRequest, ReplayPolicy, RespondSlo, Saml,
244    ///     SamlValidationContext, SpDescriptor,
245    /// };
246    /// use std::time::SystemTime;
247    ///
248    /// # fn respond(
249    /// #     idp: &Saml<saml_rs::Idp>,
250    /// #     sp: &SpDescriptor,
251    /// #     fields: Vec<FormField>,
252    /// # ) -> Result<(), saml_rs::SamlError> {
253    /// let validation = SamlValidationContext::new(
254    ///     SystemTime::now(),
255    ///     ReplayPolicy::DisabledForCompatibility,
256    /// );
257    /// let input = BrowserInput::<LogoutRequest>::post(fields);
258    /// let request = idp.receive_slo(sp, input, validation)?;
259    /// let response = idp.respond_slo(sp, &request, RespondSlo::post())?;
260    ///
261    /// let form = response.post_form()?;
262    /// # let _ = form;
263    /// # Ok(()) }
264    /// ```
265    pub fn receive_slo(
266        &self,
267        sp: &SpDescriptor,
268        input: BrowserInput<LogoutRequest>,
269        validation: SamlValidationContext<'_>,
270    ) -> Result<Received<LogoutRequest>, SamlError> {
271        let raw_sp = raw_sp_descriptor(sp)?;
272        receive_slo_impl(
273            &self.raw_identity_provider().setting,
274            &self.raw_identity_provider().metadata,
275            &raw_sp.metadata,
276            input,
277            validation,
278        )
279    }
280
281    /// Respond to a received SP LogoutRequest.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`SamlError`] when SP metadata cannot be parsed, relay state is
286    /// invalid, a compatible logout endpoint or signing key is missing, the
287    /// selected binding is unsupported, or logout response creation fails.
288    ///
289    /// # Examples
290    ///
291    /// ```no_run
292    /// use saml_rs::{LogoutRequest, Received, RespondSlo, Saml, SpDescriptor};
293    ///
294    /// # fn respond(
295    /// #     idp: &Saml<saml_rs::Idp>,
296    /// #     sp: &SpDescriptor,
297    /// #     request: &Received<LogoutRequest>,
298    /// # ) -> Result<(), saml_rs::SamlError> {
299    /// let response = idp.respond_slo(sp, request, RespondSlo::post())?;
300    /// let form = response.post_form()?;
301    /// # let _ = form;
302    /// # Ok(()) }
303    /// ```
304    pub fn respond_slo(
305        &self,
306        sp: &SpDescriptor,
307        request: &Received<LogoutRequest>,
308        options: RespondSlo,
309    ) -> Result<Outbound<LogoutResponse>, SamlError> {
310        let raw_sp = raw_sp_descriptor(sp)?;
311        respond_slo_impl(
312            &self.raw_identity_provider().setting,
313            &self.raw_identity_provider().metadata,
314            &raw_sp.metadata,
315            request,
316            options,
317        )
318    }
319
320    /// Finish IdP-initiated Single Logout using stored pending LogoutRequest state.
321    ///
322    /// # Errors
323    ///
324    /// Returns [`SamlError`] when the response does not match the pending
325    /// request, including issuer, binding, relay state, destination, or
326    /// `InResponseTo` mismatches; when SP metadata cannot be parsed; when XML,
327    /// signature, trust, status, or time validation fails; or when replay
328    /// validation detects a duplicate or expired message.
329    ///
330    /// # Examples
331    ///
332    /// ```no_run
333    /// use saml_rs::{
334    ///     BrowserInput, FormField, LogoutResponse, PendingLogoutRequest, ReplayPolicy,
335    ///     Saml, SamlValidationContext, SpDescriptor,
336    /// };
337    /// use std::time::SystemTime;
338    ///
339    /// # fn finish(
340    /// #     idp: &Saml<saml_rs::Idp>,
341    /// #     sp: &SpDescriptor,
342    /// #     pending: &PendingLogoutRequest,
343    /// #     fields: Vec<FormField>,
344    /// # ) -> Result<(), saml_rs::SamlError> {
345    /// let validation = SamlValidationContext::new(
346    ///     SystemTime::now(),
347    ///     ReplayPolicy::DisabledForCompatibility,
348    /// );
349    /// let completed = idp.finish_slo(
350    ///     sp,
351    ///     pending,
352    ///     BrowserInput::<LogoutResponse>::post(fields),
353    ///     validation,
354    /// )?;
355    ///
356    /// let peer = completed.peer_entity_id().as_str();
357    /// # let _ = peer;
358    /// # Ok(()) }
359    /// ```
360    pub fn finish_slo(
361        &self,
362        sp: &SpDescriptor,
363        pending: &PendingLogoutRequest,
364        input: BrowserInput<LogoutResponse>,
365        validation: SamlValidationContext<'_>,
366    ) -> Result<LogoutCompleted, SamlError> {
367        let raw_sp = raw_sp_descriptor(sp)?;
368        finish_slo_impl(
369            &self.raw_identity_provider().setting,
370            &self.raw_identity_provider().metadata,
371            sp.entity_id(),
372            &raw_sp.metadata,
373            pending,
374            input,
375            validation,
376        )
377    }
378}
379
380struct TypedLogoutSubject {
381    name_id: String,
382    session_indexes: Vec<String>,
383}
384
385#[derive(Debug, Clone, Copy)]
386enum StartSloRole {
387    SessionParticipant,
388    SessionAuthority { issuance_lifetime: time::Duration },
389}
390
391fn start_slo_impl(
392    local_setting: &EntitySetting,
393    local_metadata: &Metadata,
394    peer_entity_id: &EntityId,
395    peer_metadata: &Metadata,
396    subject: LogoutSubject,
397    options: StartSlo,
398    role: StartSloRole,
399) -> Result<Started<LogoutRequest>, SamlError> {
400    options.relay_state.validate()?;
401    let subject = typed_logout_subject(subject);
402    let (issue_instant, not_on_or_after, request_validation) = match role {
403        StartSloRole::SessionParticipant => {
404            (now_iso8601(), None, LogoutRequestValidation::Compatibility)
405        }
406        StartSloRole::SessionAuthority { issuance_lifetime } => {
407            let window = capture_idp_issuance_window(issuance_lifetime)?;
408            (
409                window.issue_instant,
410                Some(window.expiration),
411                LogoutRequestValidation::SessionAuthority,
412            )
413        }
414    };
415    let created = create_logout_request_with_session_indexes(LogoutRequestSessionIndexes {
416        init_setting: local_setting,
417        init_meta: local_metadata,
418        target_meta: peer_metadata,
419        binding: options.binding.as_binding(),
420        name_id: &subject.name_id,
421        session_indexes: &subject.session_indexes,
422        relay_state: options.relay_state.as_deref(),
423        want_signed: logout_request_signing(local_setting, options.signing),
424        issue_instant: &issue_instant,
425        not_on_or_after: not_on_or_after.as_deref(),
426        validation: request_validation,
427    })?;
428    let outbound = Outbound::<LogoutRequest>::try_from(created.context)?;
429    let mut pending = PendingLogoutRequest::try_new(
430        outbound.id().clone(),
431        options.relay_state,
432        options.binding,
433        peer_entity_id.clone(),
434    )?;
435    if matches!(role, StartSloRole::SessionAuthority { .. }) {
436        pending = pending.with_issue_instant(SamlInstant::try_new(created.issue_instant)?);
437        let expiration = created.not_on_or_after.ok_or_else(|| {
438            SamlError::Invalid(
439                "Session Authority LogoutRequest is missing its generated expiration".into(),
440            )
441        })?;
442        pending = pending.with_expiration(SamlInstant::try_new(expiration)?);
443    }
444    Ok(Started { pending, outbound })
445}
446
447fn receive_slo_impl(
448    local_setting: &EntitySetting,
449    local_metadata: &Metadata,
450    peer_metadata: &Metadata,
451    input: BrowserInput<LogoutRequest>,
452    mut validation: SamlValidationContext<'_>,
453) -> Result<Received<LogoutRequest>, SamlError> {
454    let relay_state = relay_state_from_input(&input)?;
455    let binding = LogoutBinding::try_from(input_binding(&input))?;
456    let request = HttpRequest::try_from(input)?;
457    let flow = parse_logout_request_at(
458        local_setting,
459        peer_metadata,
460        binding.as_binding(),
461        &request,
462        validation.now(),
463        validation.clock_skew().as_millis(),
464    )?;
465    let replay_deadline = crate::validator::logout_request_not_on_or_after_deadline(
466        &flow.extract,
467        validation.now_offset()?,
468        validation.clock_skew().not_on_or_after_millis(),
469    )?;
470    let logout = LogoutRequest::try_from(flow)?;
471    ensure_logout_destination(local_metadata, binding, logout.destination())?;
472    validation.check_and_store_message_replay_until(
473        ReplayKey::LogoutRequestId(logout.id().clone()),
474        replay_deadline,
475    )?;
476    Ok(Received::new(logout).with_relay_state(relay_state))
477}
478
479fn respond_slo_impl(
480    local_setting: &EntitySetting,
481    local_metadata: &Metadata,
482    peer_metadata: &Metadata,
483    request: &Received<LogoutRequest>,
484    options: RespondSlo,
485) -> Result<Outbound<LogoutResponse>, SamlError> {
486    let relay_state = options
487        .relay_state
488        .unwrap_or_else(|| request.relay_state().clone());
489    relay_state.validate()?;
490    let context = create_logout_response(
491        local_setting,
492        local_metadata,
493        peer_metadata,
494        options.binding.as_binding(),
495        Some(request.message().id().as_str()),
496        relay_state.as_deref(),
497        // SAML Profiles 2.0 ยงยง4.4.3.4 and 4.4.4.2 require front-channel
498        // LogoutResponses to authenticate the responder and protect integrity.
499        true,
500    )?;
501    Outbound::<LogoutResponse>::try_from(context)
502}
503
504fn finish_slo_impl(
505    local_setting: &EntitySetting,
506    local_metadata: &Metadata,
507    peer_entity_id: &EntityId,
508    peer_metadata: &Metadata,
509    pending: &PendingLogoutRequest,
510    input: BrowserInput<LogoutResponse>,
511    mut validation: SamlValidationContext<'_>,
512) -> Result<LogoutCompleted, SamlError> {
513    ensure_entity_id(pending.peer_entity_id(), peer_entity_id)?;
514    ensure_logout_response_binding(input_binding(&input), pending.response_binding())?;
515    ensure_relay_state(pending.relay_state(), &relay_state_from_input(&input)?)?;
516    let request = HttpRequest::try_from(input)?;
517    let flow = parse_logout_response_at(
518        local_setting,
519        peer_metadata,
520        pending.response_binding().as_binding(),
521        &request,
522        pending.id().as_str(),
523        validation.now(),
524        validation.clock_skew().as_millis(),
525    )?;
526    let response = LogoutResponse::try_from(flow)?;
527    ensure_logout_destination(
528        local_metadata,
529        pending.response_binding(),
530        response.destination(),
531    )?;
532    validation
533        .check_and_store_message_replay(ReplayKey::LogoutResponseId(response.id().clone()))?;
534    Ok(LogoutCompleted::from_response(
535        peer_entity_id.clone(),
536        response,
537    ))
538}
539
540fn logout_request_signing(setting: &EntitySetting, signing: LogoutSigning) -> bool {
541    match signing {
542        LogoutSigning::FollowLocalPolicy => setting.want_logout_request_signed,
543        LogoutSigning::Sign => true,
544        LogoutSigning::DoNotSignForCompatibility => false,
545    }
546}
547
548fn ensure_logout_response_binding(
549    actual: Binding,
550    expected: LogoutBinding,
551) -> Result<(), SamlError> {
552    if actual == expected.as_binding() {
553        return Ok(());
554    }
555    Err(Error::UnsupportedBinding { binding: actual })
556}
557
558fn ensure_logout_destination(
559    local_metadata: &Metadata,
560    binding: LogoutBinding,
561    actual: Option<&crate::model::EndpointUrl>,
562) -> Result<(), SamlError> {
563    let Some(actual) = actual else {
564        return Ok(());
565    };
566    let expected = local_metadata
567        .get_single_logout_service(binding.as_binding())
568        .ok_or_else(|| Error::MissingMetadata("SingleLogoutService".into()))?;
569    if actual.as_str() == expected {
570        return Ok(());
571    }
572    Err(Error::destination_mismatch(
573        &expected,
574        Some(actual.as_str()),
575    ))
576}
577
578fn typed_logout_subject(subject: LogoutSubject) -> TypedLogoutSubject {
579    TypedLogoutSubject {
580        name_id: subject.name_id().value().to_string(),
581        session_indexes: subject
582            .session_indexes()
583            .iter()
584            .map(|session_index| session_index.as_str().to_string())
585            .collect(),
586    }
587}