Skip to main content

saml_rs/browser/
pending.rs

1use core::marker::PhantomData;
2
3use super::bindings::{LogoutBinding, SsoRequestBinding, SsoResponseBinding};
4use super::endpoints::AcsEndpoint;
5use super::outbound::Outbound;
6use crate::config::EntityId;
7use crate::constants::Binding;
8use crate::error::SamlError;
9use crate::model::{
10    AuthnRequest, EndpointUrl, LogoutRequest, MessageId, RelayStateParam, SamlInstant,
11};
12
13mod sealed {
14    pub trait Sealed {}
15}
16
17/// Message marker support for typed pending state.
18#[doc(hidden)]
19pub trait PendingMessage: sealed::Sealed {
20    /// Private storage for protocol-specific pending fields.
21    type Details: Clone + core::fmt::Debug + PartialEq + Eq;
22    /// Binding type used for outbound request dispatch.
23    type RequestBinding: Copy + core::fmt::Debug + PartialEq + Eq;
24    /// Binding type expected for the correlated response.
25    type ResponseBinding: Copy + core::fmt::Debug + PartialEq + Eq;
26}
27
28/// Protocol-specific pending AuthnRequest details.
29#[doc(hidden)]
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AuthnPendingDetails {
32    request_binding: Option<SsoRequestBinding>,
33    response_binding: SsoResponseBinding,
34    acs: AcsEndpoint,
35}
36
37/// Protocol-specific pending LogoutRequest details.
38#[doc(hidden)]
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LogoutPendingDetails {
41    binding: LogoutBinding,
42}
43
44impl sealed::Sealed for AuthnRequest {}
45
46impl PendingMessage for AuthnRequest {
47    type Details = AuthnPendingDetails;
48    type RequestBinding = SsoRequestBinding;
49    type ResponseBinding = SsoResponseBinding;
50}
51
52impl sealed::Sealed for LogoutRequest {}
53
54impl PendingMessage for LogoutRequest {
55    type Details = LogoutPendingDetails;
56    type RequestBinding = LogoutBinding;
57    type ResponseBinding = LogoutBinding;
58}
59
60/// Persistable correlation snapshot for a pending SAML message.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct PendingSnapshot<Message: PendingMessage> {
63    /// Correlation ID.
64    pub id: String,
65    /// Exact RelayState state.
66    pub relay_state: RelayStateParam,
67    /// Peer entity ID.
68    pub peer_entity_id: String,
69    /// Expected response binding keyword.
70    pub expected_binding: String,
71    /// Selected request binding keyword, if tracked.
72    pub request_binding: Option<String>,
73    /// Selected ACS URL for AuthnRequest snapshots.
74    ///
75    /// LogoutRequest snapshots leave this empty and [`PendingLogoutRequest::from_snapshot`]
76    /// ignores it.
77    pub acs_url: String,
78    /// Selected ACS binding keyword for AuthnRequest snapshots.
79    ///
80    /// LogoutRequest snapshots leave this empty and [`PendingLogoutRequest::from_snapshot`]
81    /// ignores it.
82    pub acs_binding: String,
83    /// Selected ACS index for AuthnRequest snapshots, if any.
84    ///
85    /// LogoutRequest snapshots leave this unset and [`PendingLogoutRequest::from_snapshot`]
86    /// ignores it.
87    pub acs_index: Option<u16>,
88    /// Whether the selected AuthnRequest ACS was default.
89    ///
90    /// LogoutRequest snapshots leave this false and [`PendingLogoutRequest::from_snapshot`]
91    /// ignores it.
92    pub acs_is_default: bool,
93    /// Issue instant, if recorded.
94    pub issued_at: Option<SamlInstant>,
95    /// Expiration instant, if recorded.
96    pub expires_at: Option<SamlInstant>,
97    _message: PhantomData<Message>,
98}
99
100impl PendingSnapshot<AuthnRequest> {
101    /// Build an AuthnRequest snapshot from persistence fields.
102    ///
103    /// Snapshots store correlation data without copying keys or peer metadata.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use saml_rs::{AuthnRequest, PendingSnapshot, RelayStateParam};
109    ///
110    /// let snapshot = PendingSnapshot::<AuthnRequest>::authn_request(
111    ///     "_request123",
112    ///     RelayStateParam::absent(),
113    ///     "https://idp.example.com/metadata",
114    ///     "post",
115    ///     "https://sp.example.com/acs",
116    ///     "post",
117    /// );
118    ///
119    /// assert_eq!(snapshot.peer_entity_id, "https://idp.example.com/metadata");
120    /// assert_eq!(snapshot.acs_url, "https://sp.example.com/acs");
121    /// ```
122    pub fn authn_request(
123        id: impl Into<String>,
124        relay_state: RelayStateParam,
125        peer_entity_id: impl Into<String>,
126        expected_binding: impl Into<String>,
127        acs_url: impl Into<String>,
128        acs_binding: impl Into<String>,
129    ) -> Self {
130        Self {
131            id: id.into(),
132            relay_state,
133            peer_entity_id: peer_entity_id.into(),
134            expected_binding: expected_binding.into(),
135            request_binding: None,
136            acs_url: acs_url.into(),
137            acs_binding: acs_binding.into(),
138            acs_index: None,
139            acs_is_default: false,
140            issued_at: None,
141            expires_at: None,
142            _message: PhantomData,
143        }
144    }
145}
146
147impl PendingSnapshot<LogoutRequest> {
148    /// Build a LogoutRequest snapshot from persistence fields.
149    ///
150    /// ACS fields are AuthnRequest-only and are intentionally left empty.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use saml_rs::{LogoutBinding, LogoutRequest, PendingSnapshot, RelayStateParam};
156    ///
157    /// let snapshot = PendingSnapshot::<LogoutRequest>::logout_request(
158    ///     "_logout123",
159    ///     RelayStateParam::absent(),
160    ///     "https://idp.example.com/metadata",
161    ///     LogoutBinding::Post,
162    /// );
163    ///
164    /// assert_eq!(snapshot.expected_binding, "post");
165    /// assert!(snapshot.acs_url.is_empty());
166    /// ```
167    pub fn logout_request(
168        id: impl Into<String>,
169        relay_state: RelayStateParam,
170        peer_entity_id: impl Into<String>,
171        binding: LogoutBinding,
172    ) -> Self {
173        Self {
174            id: id.into(),
175            relay_state,
176            peer_entity_id: peer_entity_id.into(),
177            expected_binding: binding.as_binding().short_name().to_string(),
178            request_binding: Some(binding.as_binding().short_name().to_string()),
179            acs_url: String::new(),
180            acs_binding: String::new(),
181            acs_index: None,
182            acs_is_default: false,
183            issued_at: None,
184            expires_at: None,
185            _message: PhantomData,
186        }
187    }
188}
189
190/// Pending SAML message correlation state.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Pending<Message: PendingMessage> {
193    id: MessageId,
194    relay_state: RelayStateParam,
195    details: Message::Details,
196    peer_entity_id: EntityId,
197    issued_at: Option<SamlInstant>,
198    expires_at: Option<SamlInstant>,
199    _message: PhantomData<Message>,
200}
201
202/// Pending AuthnRequest correlation state.
203pub type PendingAuthnRequest = Pending<AuthnRequest>;
204
205/// Pending LogoutRequest correlation state.
206pub type PendingLogoutRequest = Pending<LogoutRequest>;
207
208impl<Message: PendingMessage> Pending<Message> {
209    fn validate_common(
210        relay_state: &RelayStateParam,
211        peer_entity_id: &EntityId,
212    ) -> Result<(), SamlError> {
213        relay_state.validate()?;
214        EntityId::try_new(peer_entity_id.as_str().to_string())?;
215        Ok(())
216    }
217
218    fn validate_snapshot_timing(
219        issued_at: &Option<SamlInstant>,
220        expires_at: &Option<SamlInstant>,
221    ) -> Result<(), SamlError> {
222        if expires_at.is_some() && issued_at.is_none() {
223            return Err(SamlError::Invalid(
224                "pending snapshot expiration requires an issue instant".into(),
225            ));
226        }
227        Ok(())
228    }
229
230    /// Record an issue instant.
231    pub fn with_issue_instant(mut self, issued_at: SamlInstant) -> Self {
232        self.issued_at = Some(issued_at);
233        self
234    }
235
236    /// Record an expiration instant.
237    pub fn with_expiration(mut self, expires_at: SamlInstant) -> Self {
238        self.expires_at = Some(expires_at);
239        self
240    }
241
242    /// SAML protocol message ID.
243    pub fn id(&self) -> &MessageId {
244        &self.id
245    }
246
247    /// RelayState state.
248    pub fn relay_state(&self) -> &RelayStateParam {
249        &self.relay_state
250    }
251
252    /// Peer entity ID.
253    pub fn peer_entity_id(&self) -> &EntityId {
254        &self.peer_entity_id
255    }
256
257    /// Issue instant, if recorded.
258    pub fn issued_at(&self) -> Option<&SamlInstant> {
259        self.issued_at.as_ref()
260    }
261
262    /// Expiration instant, if recorded.
263    pub fn expires_at(&self) -> Option<&SamlInstant> {
264        self.expires_at.as_ref()
265    }
266}
267
268impl Pending<AuthnRequest> {
269    /// Create pending AuthnRequest state without storing keys or metadata.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`SamlError::Invalid`] when the RelayState state is malformed,
274    /// the IdP entity ID is empty, or the selected ACS binding does not match
275    /// the expected response binding.
276    pub fn try_new(
277        request_id: MessageId,
278        relay_state: RelayStateParam,
279        acs: AcsEndpoint,
280        response_binding: SsoResponseBinding,
281        idp_entity_id: EntityId,
282    ) -> Result<Self, SamlError> {
283        Self::validate_common(&relay_state, &idp_entity_id)?;
284        if acs.binding() != response_binding {
285            return Err(SamlError::Invalid(
286                "ACS binding must match expected response binding".into(),
287            ));
288        }
289        Ok(Self {
290            id: request_id,
291            relay_state,
292            details: AuthnPendingDetails {
293                request_binding: None,
294                response_binding,
295                acs,
296            },
297            peer_entity_id: idp_entity_id,
298            issued_at: None,
299            expires_at: None,
300            _message: PhantomData,
301        })
302    }
303
304    /// Record the outbound AuthnRequest binding selected for dispatch.
305    pub fn with_request_binding(mut self, request_binding: SsoRequestBinding) -> Self {
306        self.details.request_binding = Some(request_binding);
307        self
308    }
309
310    /// Reconstruct pending AuthnRequest state from a snapshot.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`SamlError`] when any snapshot field is malformed or
315    /// inconsistent.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use saml_rs::{AuthnRequest, Pending, PendingSnapshot, RelayStateParam};
321    ///
322    /// let snapshot = PendingSnapshot::<AuthnRequest>::authn_request(
323    ///     "_request123",
324    ///     RelayStateParam::absent(),
325    ///     "https://idp.example.com/metadata",
326    ///     "post",
327    ///     "https://sp.example.com/acs",
328    ///     "post",
329    /// );
330    /// let pending = Pending::<AuthnRequest>::from_snapshot(snapshot)?;
331    ///
332    /// assert_eq!(pending.idp_entity_id().as_str(), "https://idp.example.com/metadata");
333    /// # Ok::<(), saml_rs::SamlError>(())
334    /// ```
335    pub fn from_snapshot(snapshot: PendingSnapshot<AuthnRequest>) -> Result<Self, SamlError> {
336        snapshot.relay_state.validate()?;
337        Self::validate_snapshot_timing(&snapshot.issued_at, &snapshot.expires_at)?;
338        let request_id = MessageId::try_new(snapshot.id)?;
339        let idp_entity_id = EntityId::try_new(snapshot.peer_entity_id)?;
340        let response_binding =
341            sso_response_binding_from_snapshot_value(&snapshot.expected_binding)?;
342        let acs_binding = sso_response_binding_from_snapshot_value(&snapshot.acs_binding)?;
343        let mut acs = AcsEndpoint::new(acs_binding, EndpointUrl::try_new(snapshot.acs_url)?)
344            .with_default_flag(snapshot.acs_is_default);
345        if let Some(index) = snapshot.acs_index {
346            acs = acs.with_index(index);
347        }
348        let request_binding = snapshot
349            .request_binding
350            .as_deref()
351            .map(sso_request_binding_from_snapshot_value)
352            .transpose()?;
353        let mut pending = Self::try_new(
354            request_id,
355            snapshot.relay_state,
356            acs,
357            response_binding,
358            idp_entity_id,
359        )?;
360        pending.details.request_binding = request_binding;
361        pending.issued_at = snapshot.issued_at;
362        pending.expires_at = snapshot.expires_at;
363        Ok(pending)
364    }
365
366    /// Build a persistable snapshot.
367    pub fn snapshot(&self) -> PendingSnapshot<AuthnRequest> {
368        let mut snapshot = PendingSnapshot::authn_request(
369            self.id.as_str(),
370            self.relay_state.clone(),
371            self.peer_entity_id.as_str(),
372            self.details.response_binding.as_binding().short_name(),
373            self.details.acs.location().as_str(),
374            self.details.acs.binding().as_binding().short_name(),
375        );
376        snapshot.request_binding = self
377            .details
378            .request_binding
379            .map(|binding| binding.as_binding().short_name().to_string());
380        snapshot.acs_index = self.details.acs.index();
381        snapshot.acs_is_default = self.details.acs.is_default();
382        snapshot.issued_at = self.issued_at.clone();
383        snapshot.expires_at = self.expires_at.clone();
384        snapshot
385    }
386
387    /// Request ID.
388    pub fn request_id(&self) -> &MessageId {
389        &self.id
390    }
391
392    /// Request binding, when tracked.
393    pub fn request_binding(&self) -> Option<SsoRequestBinding> {
394        self.details.request_binding
395    }
396
397    /// Expected response binding.
398    pub fn response_binding(&self) -> SsoResponseBinding {
399        self.details.response_binding
400    }
401
402    /// Selected ACS endpoint.
403    pub fn acs(&self) -> &AcsEndpoint {
404        &self.details.acs
405    }
406
407    /// Selected IdP entity ID.
408    pub fn idp_entity_id(&self) -> &EntityId {
409        &self.peer_entity_id
410    }
411}
412
413impl Pending<LogoutRequest> {
414    /// Create pending LogoutRequest state without storing keys or metadata.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`SamlError::Invalid`] when RelayState or peer entity ID are malformed.
419    pub fn try_new(
420        request_id: MessageId,
421        relay_state: RelayStateParam,
422        binding: LogoutBinding,
423        peer_entity_id: EntityId,
424    ) -> Result<Self, SamlError> {
425        Self::validate_common(&relay_state, &peer_entity_id)?;
426        Ok(Self {
427            id: request_id,
428            relay_state,
429            details: LogoutPendingDetails { binding },
430            peer_entity_id,
431            issued_at: None,
432            expires_at: None,
433            _message: PhantomData,
434        })
435    }
436
437    /// Reconstruct pending LogoutRequest state from a snapshot.
438    ///
439    /// AuthnRequest-only ACS snapshot fields are ignored.
440    ///
441    /// # Errors
442    ///
443    /// Returns [`SamlError`] when any snapshot field is malformed or
444    /// inconsistent.
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// use saml_rs::{LogoutBinding, LogoutRequest, Pending, PendingSnapshot, RelayStateParam};
450    ///
451    /// let snapshot = PendingSnapshot::<LogoutRequest>::logout_request(
452    ///     "_logout123",
453    ///     RelayStateParam::absent(),
454    ///     "https://idp.example.com/metadata",
455    ///     LogoutBinding::Post,
456    /// );
457    /// let pending = Pending::<LogoutRequest>::from_snapshot(snapshot)?;
458    ///
459    /// assert_eq!(pending.peer_entity_id().as_str(), "https://idp.example.com/metadata");
460    /// # Ok::<(), saml_rs::SamlError>(())
461    /// ```
462    pub fn from_snapshot(snapshot: PendingSnapshot<LogoutRequest>) -> Result<Self, SamlError> {
463        snapshot.relay_state.validate()?;
464        Self::validate_snapshot_timing(&snapshot.issued_at, &snapshot.expires_at)?;
465        let request_id = MessageId::try_new(snapshot.id)?;
466        let peer_entity_id = EntityId::try_new(snapshot.peer_entity_id)?;
467        let response_binding = logout_binding_from_snapshot_value(&snapshot.expected_binding)?;
468        let request_binding = snapshot
469            .request_binding
470            .as_deref()
471            .map(logout_binding_from_snapshot_value)
472            .transpose()?
473            .unwrap_or(response_binding);
474        if request_binding != response_binding {
475            return Err(SamlError::Invalid(
476                "logout request and response bindings must match".into(),
477            ));
478        }
479        let mut pending = Self::try_new(
480            request_id,
481            snapshot.relay_state,
482            response_binding,
483            peer_entity_id,
484        )?;
485        pending.issued_at = snapshot.issued_at;
486        pending.expires_at = snapshot.expires_at;
487        Ok(pending)
488    }
489
490    /// Build a persistable snapshot.
491    pub fn snapshot(&self) -> PendingSnapshot<LogoutRequest> {
492        let mut snapshot = PendingSnapshot::logout_request(
493            self.id.as_str(),
494            self.relay_state.clone(),
495            self.peer_entity_id.as_str(),
496            self.details.binding,
497        );
498        snapshot.issued_at = self.issued_at.clone();
499        snapshot.expires_at = self.expires_at.clone();
500        snapshot
501    }
502
503    /// Logout request binding selected for dispatch.
504    pub fn request_binding(&self) -> LogoutBinding {
505        self.details.binding
506    }
507
508    /// Expected logout response binding.
509    pub fn response_binding(&self) -> LogoutBinding {
510        self.details.binding
511    }
512}
513
514fn sso_response_binding_from_snapshot_value(value: &str) -> Result<SsoResponseBinding, SamlError> {
515    let binding = Binding::from_short_name(value)
516        .or_else(|| Binding::from_urn(value))
517        .ok_or(SamlError::UndefinedBinding)?;
518    SsoResponseBinding::try_from(binding)
519}
520
521fn sso_request_binding_from_snapshot_value(value: &str) -> Result<SsoRequestBinding, SamlError> {
522    let binding = Binding::from_short_name(value)
523        .or_else(|| Binding::from_urn(value))
524        .ok_or(SamlError::UndefinedBinding)?;
525    SsoRequestBinding::try_from(binding)
526}
527
528fn logout_binding_from_snapshot_value(value: &str) -> Result<LogoutBinding, SamlError> {
529    let binding = Binding::from_short_name(value)
530        .or_else(|| Binding::from_urn(value))
531        .ok_or(SamlError::UndefinedBinding)?;
532    LogoutBinding::try_from(binding)
533}
534
535/// Started browser flow with pending state and outbound browser action.
536#[derive(Debug, Clone)]
537pub struct Started<Message: PendingMessage> {
538    /// Pending correlation state.
539    pub pending: Pending<Message>,
540    /// Outbound browser action.
541    pub outbound: Outbound<Message>,
542}