Skip to main content

saml_rs/model/
outstanding_logout.rs

1use super::identifiers::{SamlInstant, SessionIndex};
2use super::sso::SsoSession;
3use super::subject::NameId;
4use super::validation::SamlValidationContext;
5use crate::error::SamlError;
6use crate::validator::effective_not_on_or_after;
7use crate::xml::parse_saml_utc_date_time;
8use time::{format_description::well_known::Rfc3339, OffsetDateTime};
9
10/// A `LogoutRequest` the caller has already accepted.
11///
12/// Pass it to [`Saml<Sp>::finish_sso_with_outstanding_logout`] or
13/// [`Saml<Sp>::accept_unsolicited_sso_with_outstanding_logout`]. The library
14/// does not store it. NameID values must be equal. A `Format` on the logout
15/// must match; an omitted `Format` compares values only. An empty session list
16/// applies to every session of that principal. The classification is in
17/// `docs/conformance/web-browser-sso-acceptance.md`.
18///
19/// [`Saml<Sp>::finish_sso_with_outstanding_logout`]: crate::Saml::finish_sso_with_outstanding_logout
20/// [`Saml<Sp>::accept_unsolicited_sso_with_outstanding_logout`]: crate::Saml::accept_unsolicited_sso_with_outstanding_logout
21///
22/// # Examples
23///
24/// ```
25/// use saml_rs::{NameId, NameIdFormat, OutstandingLogout, SamlInstant, SessionIndex};
26///
27/// # fn main() -> Result<(), saml_rs::SamlError> {
28/// let logout = OutstandingLogout::try_new(
29///     NameId::new(
30///         "alice@example.com",
31///         Some(NameIdFormat::EmailAddress),
32///     ),
33///     vec![SessionIndex::try_new("_session")?],
34///     SamlInstant::try_new("2026-10-06T00:00:00Z")?,
35/// )?;
36/// # let _ = logout;
37/// # Ok(()) }
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct OutstandingLogout {
41    principal: NameId,
42    sessions: Vec<SessionIndex>,
43    not_on_or_after: SamlInstant,
44    deadline: OffsetDateTime,
45}
46
47impl OutstandingLogout {
48    /// Create an outstanding logout from the principal, sessions, and
49    /// `NotOnOrAfter` the caller accepted.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`SamlError::Invalid`] when the principal value is empty or
54    /// `not_on_or_after` is not a representable SAML UTC `xs:dateTime`.
55    pub fn try_new(
56        principal: NameId,
57        sessions: Vec<SessionIndex>,
58        not_on_or_after: SamlInstant,
59    ) -> Result<Self, SamlError> {
60        if principal.value().trim().is_empty() {
61            return Err(SamlError::Invalid(
62                "outstanding logout principal must not be empty".into(),
63            ));
64        }
65        let normalized = parse_saml_utc_date_time(not_on_or_after.as_str()).ok_or_else(|| {
66            SamlError::Invalid(
67                "outstanding logout NotOnOrAfter must be a SAML UTC xs:dateTime ending in Z".into(),
68            )
69        })?;
70        let deadline = OffsetDateTime::parse(normalized, &Rfc3339).map_err(|_| {
71            SamlError::Invalid(
72                "outstanding logout NotOnOrAfter cannot be represented as a UTC instant".into(),
73            )
74        })?;
75        let not_on_or_after = SamlInstant::try_new(normalized)?;
76        Ok(Self {
77            principal,
78            sessions,
79            not_on_or_after,
80            deadline,
81        })
82    }
83
84    /// Principal named by the accepted logout.
85    pub fn principal(&self) -> &NameId {
86        &self.principal
87    }
88
89    /// Sessions named by the accepted logout.
90    ///
91    /// Empty means the logout carried no `SessionIndex`.
92    pub fn sessions(&self) -> &[SessionIndex] {
93        &self.sessions
94    }
95
96    /// `NotOnOrAfter` from the accepted logout, normalized as a SAML UTC instant.
97    pub fn not_on_or_after(&self) -> &SamlInstant {
98        &self.not_on_or_after
99    }
100
101    pub(crate) fn matches_unexpired(
102        &self,
103        session: &SsoSession,
104        validation: &SamlValidationContext<'_>,
105    ) -> Result<bool, SamlError> {
106        if !self.same_principal(session.name_id()) || !self.same_session(session) {
107            return Ok(false);
108        }
109        let now = validation.now_offset()?;
110        let effective = effective_not_on_or_after(
111            self.deadline,
112            validation.clock_skew().not_on_or_after_millis(),
113        )?;
114        Ok(now < effective)
115    }
116
117    fn same_principal(&self, name_id: &NameId) -> bool {
118        if self.principal.value() != name_id.value() {
119            return false;
120        }
121        self.principal
122            .format()
123            .is_none_or(|format| name_id.format() == Some(format))
124    }
125
126    fn same_session(&self, session: &SsoSession) -> bool {
127        if self.sessions.is_empty() {
128            return true;
129        }
130        session.authn_sessions().iter().any(|authn| {
131            authn
132                .session_index()
133                .is_some_and(|index| self.sessions.contains(index))
134        })
135    }
136}