Skip to main content

saml_rs/model/
logout.rs

1use super::extract::{
2    optional_endpoint, optional_request_id, required_str, session_indexes_from_value,
3};
4use super::identifiers::{MessageId, SamlInstant, SessionIndex};
5use super::subject::NameId;
6use super::EndpointUrl;
7use crate::config::EntityId;
8use crate::constants::status_code;
9use crate::error::SamlError;
10use crate::raw::FlowResult;
11use crate::xml::parse_saml_utc_date_time;
12
13/// Subject data used to issue a front-channel Single Logout request.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct LogoutSubject {
16    name_id: NameId,
17    session_indexes: Vec<SessionIndex>,
18}
19
20impl LogoutSubject {
21    /// Create logout subject data from a NameID and SessionIndex values.
22    pub fn new(name_id: NameId, session_indexes: Vec<SessionIndex>) -> Self {
23        Self {
24            name_id,
25            session_indexes,
26        }
27    }
28
29    /// Create logout subject data with no SessionIndex values.
30    pub fn from_name_id(name_id: NameId) -> Self {
31        Self::new(name_id, Vec::new())
32    }
33
34    /// Create logout subject data with one SessionIndex.
35    pub fn with_session_index(name_id: NameId, session_index: SessionIndex) -> Self {
36        Self::new(name_id, vec![session_index])
37    }
38
39    /// Subject NameID.
40    pub fn name_id(&self) -> &NameId {
41        &self.name_id
42    }
43
44    /// First SessionIndex to include in the logout request, when present.
45    pub fn session_index(&self) -> Option<&SessionIndex> {
46        self.session_indexes.first()
47    }
48
49    /// SessionIndex values to include in the logout request.
50    pub fn session_indexes(&self) -> &[SessionIndex] {
51        &self.session_indexes
52    }
53}
54
55/// Parsed LogoutRequest result.
56#[derive(Debug, Clone)]
57pub struct LogoutRequest {
58    id: MessageId,
59    issue_instant: SamlInstant,
60    not_on_or_after: Option<SamlInstant>,
61    issuer: EntityId,
62    name_id: Option<NameId>,
63    session_indexes: Vec<SessionIndex>,
64    destination: Option<EndpointUrl>,
65    raw_flow: FlowResult,
66}
67
68impl LogoutRequest {
69    /// LogoutRequest ID.
70    pub fn id(&self) -> &MessageId {
71        &self.id
72    }
73
74    /// LogoutRequest `IssueInstant`, normalized according to XML Schema whitespace rules.
75    pub fn issue_instant(&self) -> &SamlInstant {
76        &self.issue_instant
77    }
78
79    /// LogoutRequest `NotOnOrAfter`, normalized according to XML Schema whitespace rules.
80    pub fn not_on_or_after(&self) -> Option<&SamlInstant> {
81        self.not_on_or_after.as_ref()
82    }
83
84    /// LogoutRequest issuer.
85    pub fn issuer(&self) -> &EntityId {
86        &self.issuer
87    }
88
89    /// NameID, when present.
90    pub fn name_id(&self) -> Option<&NameId> {
91        self.name_id.as_ref()
92    }
93
94    /// Session indexes.
95    pub fn session_indexes(&self) -> &[SessionIndex] {
96        &self.session_indexes
97    }
98
99    /// Destination endpoint, when present.
100    pub fn destination(&self) -> Option<&EndpointUrl> {
101        self.destination.as_ref()
102    }
103
104    /// Underlying raw compatibility flow result.
105    ///
106    /// When this model is built directly with [`TryFrom<FlowResult>`], no typed
107    /// receiver endpoint validation is implied.
108    pub fn raw_flow(&self) -> &FlowResult {
109        &self.raw_flow
110    }
111}
112
113impl TryFrom<FlowResult> for LogoutRequest {
114    type Error = SamlError;
115
116    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
117        let id = MessageId::try_new(required_str(&raw_flow.extract, "request.id")?)?;
118        let issue_instant = logout_request_instant_from_extract(
119            &raw_flow.extract,
120            "issueInstant",
121            "IssueInstant",
122            true,
123        )?
124        .ok_or_else(|| {
125            SamlError::ProtocolProfile(
126                "LogoutRequest is missing required unqualified attribute IssueInstant".into(),
127            )
128        })?;
129        let not_on_or_after = logout_request_instant_from_extract(
130            &raw_flow.extract,
131            "notOnOrAfter",
132            "NotOnOrAfter",
133            false,
134        )?;
135        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
136        let name_id = raw_flow
137            .extract
138            .get_str("nameID")
139            .map(|value| NameId::new(value, None));
140        let session_indexes = session_indexes_from_value(raw_flow.extract.get("sessionIndex"))?;
141        let destination = optional_endpoint(&raw_flow.extract, "request.destination")?;
142        Ok(Self {
143            id,
144            issue_instant,
145            not_on_or_after,
146            issuer,
147            name_id,
148            session_indexes,
149            destination,
150            raw_flow,
151        })
152    }
153}
154
155fn logout_request_instant_from_extract(
156    extract: &crate::util::Value,
157    extract_field: &str,
158    attribute: &str,
159    required: bool,
160) -> Result<Option<SamlInstant>, SamlError> {
161    let path = format!("request.{extract_field}");
162    let Some(value) = extract.get_str(&path) else {
163        if required {
164            return Err(SamlError::ProtocolProfile(format!(
165                "LogoutRequest is missing required unqualified attribute {attribute}"
166            )));
167        }
168        return Ok(None);
169    };
170    let normalized = parse_saml_utc_date_time(value).ok_or_else(|| {
171        SamlError::ProtocolProfile(format!(
172            "LogoutRequest {attribute} must use the SAML-conformant UTC xs:dateTime form ending in Z"
173        ))
174    })?;
175    SamlInstant::try_new(normalized).map(Some)
176}
177
178/// Parsed LogoutResponse result.
179#[derive(Debug, Clone)]
180pub struct LogoutResponse {
181    id: MessageId,
182    issuer: EntityId,
183    issue_instant: SamlInstant,
184    in_response_to: Option<MessageId>,
185    destination: Option<EndpointUrl>,
186    raw_flow: FlowResult,
187}
188
189impl LogoutResponse {
190    /// LogoutResponse ID.
191    pub fn id(&self) -> &MessageId {
192        &self.id
193    }
194
195    /// LogoutResponse issuer.
196    pub fn issuer(&self) -> &EntityId {
197        &self.issuer
198    }
199
200    /// LogoutResponse `IssueInstant`, normalized according to XML Schema whitespace rules.
201    pub fn issue_instant(&self) -> &SamlInstant {
202        &self.issue_instant
203    }
204
205    /// InResponseTo, when present.
206    pub fn in_response_to(&self) -> Option<&MessageId> {
207        self.in_response_to.as_ref()
208    }
209
210    /// Destination endpoint, when present.
211    pub fn destination(&self) -> Option<&EndpointUrl> {
212        self.destination.as_ref()
213    }
214
215    /// Underlying raw compatibility flow result.
216    ///
217    /// When this model is built directly with [`TryFrom<FlowResult>`], no typed
218    /// receiver endpoint validation is implied.
219    pub fn raw_flow(&self) -> &FlowResult {
220        &self.raw_flow
221    }
222}
223
224impl TryFrom<FlowResult> for LogoutResponse {
225    type Error = SamlError;
226
227    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
228        let id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
229        let issue_instant = issue_instant_from_extract(&raw_flow.extract)?;
230        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
231        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
232        let destination = optional_endpoint(&raw_flow.extract, "response.destination")?;
233        Ok(Self {
234            id,
235            issuer,
236            issue_instant,
237            in_response_to,
238            destination,
239            raw_flow,
240        })
241    }
242}
243
244fn issue_instant_from_extract(extract: &crate::util::Value) -> Result<SamlInstant, SamlError> {
245    let issue_instant = extract.get_str("response.issueInstant").ok_or_else(|| {
246        SamlError::ProtocolProfile(
247            "LogoutResponse is missing required unqualified attribute IssueInstant".into(),
248        )
249    })?;
250    let issue_instant = parse_saml_utc_date_time(issue_instant).ok_or_else(|| {
251        SamlError::ProtocolProfile(
252            "LogoutResponse IssueInstant must use the SAML-conformant UTC xs:dateTime form ending in Z"
253                .into(),
254        )
255    })?;
256    SamlInstant::try_new(issue_instant)
257}
258
259/// Marker result for completed logout flows.
260#[derive(Debug, Clone)]
261pub struct LogoutCompleted {
262    peer_entity_id: EntityId,
263    response: Option<LogoutResponse>,
264}
265
266impl PartialEq for LogoutCompleted {
267    fn eq(&self, other: &Self) -> bool {
268        self.peer_entity_id == other.peer_entity_id
269    }
270}
271
272impl Eq for LogoutCompleted {}
273
274impl LogoutCompleted {
275    /// Create a completed logout marker.
276    pub fn new(peer_entity_id: EntityId) -> Self {
277        Self {
278            peer_entity_id,
279            response: None,
280        }
281    }
282
283    /// Create a completed logout marker from a validated LogoutResponse.
284    pub fn from_response(peer_entity_id: EntityId, response: LogoutResponse) -> Self {
285        Self {
286            peer_entity_id,
287            response: Some(response),
288        }
289    }
290
291    /// Peer entity ID involved in the completed logout.
292    pub fn peer_entity_id(&self) -> &EntityId {
293        &self.peer_entity_id
294    }
295
296    /// Validated LogoutResponse, when this completion came from a front-channel response.
297    pub fn response(&self) -> Option<&LogoutResponse> {
298        self.response.as_ref()
299    }
300
301    /// Successful SAML status for the completed logout response.
302    pub fn status(&self) -> Option<&str> {
303        self.response.as_ref().map(|_| status_code::SUCCESS)
304    }
305
306    /// Underlying raw compatibility flow result for the LogoutResponse.
307    pub fn raw_flow(&self) -> Option<&FlowResult> {
308        self.response.as_ref().map(LogoutResponse::raw_flow)
309    }
310}