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    /// Raw validated flow result.
105    pub fn raw_flow(&self) -> &FlowResult {
106        &self.raw_flow
107    }
108}
109
110impl TryFrom<FlowResult> for LogoutRequest {
111    type Error = SamlError;
112
113    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
114        let id = MessageId::try_new(required_str(&raw_flow.extract, "request.id")?)?;
115        let issue_instant = logout_request_instant_from_extract(
116            &raw_flow.extract,
117            "issueInstant",
118            "IssueInstant",
119            true,
120        )?
121        .ok_or_else(|| {
122            SamlError::ProtocolProfile(
123                "LogoutRequest is missing required unqualified attribute IssueInstant".into(),
124            )
125        })?;
126        let not_on_or_after = logout_request_instant_from_extract(
127            &raw_flow.extract,
128            "notOnOrAfter",
129            "NotOnOrAfter",
130            false,
131        )?;
132        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
133        let name_id = raw_flow
134            .extract
135            .get_str("nameID")
136            .map(|value| NameId::new(value, None));
137        let session_indexes = session_indexes_from_value(raw_flow.extract.get("sessionIndex"))?;
138        let destination = optional_endpoint(&raw_flow.extract, "request.destination")?;
139        Ok(Self {
140            id,
141            issue_instant,
142            not_on_or_after,
143            issuer,
144            name_id,
145            session_indexes,
146            destination,
147            raw_flow,
148        })
149    }
150}
151
152fn logout_request_instant_from_extract(
153    extract: &crate::util::Value,
154    extract_field: &str,
155    attribute: &str,
156    required: bool,
157) -> Result<Option<SamlInstant>, SamlError> {
158    let path = format!("request.{extract_field}");
159    let Some(value) = extract.get_str(&path) else {
160        if required {
161            return Err(SamlError::ProtocolProfile(format!(
162                "LogoutRequest is missing required unqualified attribute {attribute}"
163            )));
164        }
165        return Ok(None);
166    };
167    let normalized = parse_saml_utc_date_time(value).ok_or_else(|| {
168        SamlError::ProtocolProfile(format!(
169            "LogoutRequest {attribute} must use the SAML-conformant UTC xs:dateTime form ending in Z"
170        ))
171    })?;
172    SamlInstant::try_new(normalized).map(Some)
173}
174
175/// Parsed LogoutResponse result.
176#[derive(Debug, Clone)]
177pub struct LogoutResponse {
178    id: MessageId,
179    issuer: EntityId,
180    issue_instant: SamlInstant,
181    in_response_to: Option<MessageId>,
182    destination: Option<EndpointUrl>,
183    raw_flow: FlowResult,
184}
185
186impl LogoutResponse {
187    /// LogoutResponse ID.
188    pub fn id(&self) -> &MessageId {
189        &self.id
190    }
191
192    /// LogoutResponse issuer.
193    pub fn issuer(&self) -> &EntityId {
194        &self.issuer
195    }
196
197    /// LogoutResponse `IssueInstant`, normalized according to XML Schema whitespace rules.
198    pub fn issue_instant(&self) -> &SamlInstant {
199        &self.issue_instant
200    }
201
202    /// InResponseTo, when present.
203    pub fn in_response_to(&self) -> Option<&MessageId> {
204        self.in_response_to.as_ref()
205    }
206
207    /// Destination endpoint, when present.
208    pub fn destination(&self) -> Option<&EndpointUrl> {
209        self.destination.as_ref()
210    }
211
212    /// Raw validated flow result.
213    pub fn raw_flow(&self) -> &FlowResult {
214        &self.raw_flow
215    }
216}
217
218impl TryFrom<FlowResult> for LogoutResponse {
219    type Error = SamlError;
220
221    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
222        let id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
223        let issue_instant = issue_instant_from_extract(&raw_flow.extract)?;
224        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
225        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
226        let destination = optional_endpoint(&raw_flow.extract, "response.destination")?;
227        Ok(Self {
228            id,
229            issuer,
230            issue_instant,
231            in_response_to,
232            destination,
233            raw_flow,
234        })
235    }
236}
237
238fn issue_instant_from_extract(extract: &crate::util::Value) -> Result<SamlInstant, SamlError> {
239    let issue_instant = extract.get_str("response.issueInstant").ok_or_else(|| {
240        SamlError::ProtocolProfile(
241            "LogoutResponse is missing required unqualified attribute IssueInstant".into(),
242        )
243    })?;
244    let issue_instant = parse_saml_utc_date_time(issue_instant).ok_or_else(|| {
245        SamlError::ProtocolProfile(
246            "LogoutResponse IssueInstant must use the SAML-conformant UTC xs:dateTime form ending in Z"
247                .into(),
248        )
249    })?;
250    SamlInstant::try_new(issue_instant)
251}
252
253/// Marker result for completed logout flows.
254#[derive(Debug, Clone)]
255pub struct LogoutCompleted {
256    peer_entity_id: EntityId,
257    response: Option<LogoutResponse>,
258}
259
260impl PartialEq for LogoutCompleted {
261    fn eq(&self, other: &Self) -> bool {
262        self.peer_entity_id == other.peer_entity_id
263    }
264}
265
266impl Eq for LogoutCompleted {}
267
268impl LogoutCompleted {
269    /// Create a completed logout marker.
270    pub fn new(peer_entity_id: EntityId) -> Self {
271        Self {
272            peer_entity_id,
273            response: None,
274        }
275    }
276
277    /// Create a completed logout marker from a validated LogoutResponse.
278    pub fn from_response(peer_entity_id: EntityId, response: LogoutResponse) -> Self {
279        Self {
280            peer_entity_id,
281            response: Some(response),
282        }
283    }
284
285    /// Peer entity ID involved in the completed logout.
286    pub fn peer_entity_id(&self) -> &EntityId {
287        &self.peer_entity_id
288    }
289
290    /// Validated LogoutResponse, when this completion came from a front-channel response.
291    pub fn response(&self) -> Option<&LogoutResponse> {
292        self.response.as_ref()
293    }
294
295    /// Successful SAML status for the completed logout response.
296    pub fn status(&self) -> Option<&str> {
297        self.response.as_ref().map(|_| status_code::SUCCESS)
298    }
299
300    /// Raw validated flow result for the LogoutResponse.
301    pub fn raw_flow(&self) -> Option<&FlowResult> {
302        self.response.as_ref().map(LogoutResponse::raw_flow)
303    }
304}