Skip to main content

saml_rs/browser/
outbound.rs

1use core::marker::PhantomData;
2
3use super::forms::{FormField, PostForm};
4use crate::constants::url_params;
5use crate::constants::Binding;
6use crate::entity::BindingContext;
7use crate::error::SamlError;
8use crate::model::{
9    AuthnRequest, EndpointUrl, LogoutRequest, LogoutResponse, MessageId, RelayState, SsoResponse,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13enum OutboundKind {
14    Redirect,
15    Post,
16    SimpleSignPost,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum MessageField {
21    Request,
22    Response,
23}
24
25impl MessageField {
26    fn param_name(self) -> &'static str {
27        match self {
28            Self::Request => url_params::SAML_REQUEST,
29            Self::Response => url_params::SAML_RESPONSE,
30        }
31    }
32
33    fn opposite_param_name(self) -> &'static str {
34        match self {
35            Self::Request => url_params::SAML_RESPONSE,
36            Self::Response => url_params::SAML_REQUEST,
37        }
38    }
39}
40
41/// Typed outbound browser action.
42///
43/// # Examples
44///
45/// ```
46/// use saml_rs::{AuthnRequest, Outbound};
47/// # use saml_rs::raw::{Binding, BindingContext};
48/// #
49/// # fn redirect_context() -> BindingContext {
50/// #     BindingContext {
51/// #         id: "_request123".to_string(),
52/// #         context: "https://idp.example.com/sso?SAMLRequest=abc".to_string(),
53/// #         relay_state: None,
54/// #         entity_endpoint: "https://idp.example.com/sso".to_string(),
55/// #         binding: Binding::Redirect,
56/// #         request_type: "SAMLRequest",
57/// #         signature: None,
58/// #         sig_alg: None,
59/// #     }
60/// # }
61/// # fn post_context() -> BindingContext {
62/// #     BindingContext {
63/// #         id: "_request456".to_string(),
64/// #         context: "PHNhbWxwOkF1dGhuUmVxdWVzdA==".to_string(),
65/// #         relay_state: None,
66/// #         entity_endpoint: "https://idp.example.com/sso".to_string(),
67/// #         binding: Binding::Post,
68/// #         request_type: "SAMLRequest",
69/// #         signature: None,
70/// #         sig_alg: None,
71/// #     }
72/// # }
73///
74/// let redirect = Outbound::<AuthnRequest>::try_from(redirect_context())?;
75/// assert!(redirect.redirect_url()?.contains("SAMLRequest="));
76///
77/// let post = Outbound::<AuthnRequest>::try_from(post_context())?;
78/// assert_eq!(post.post_form()?.action().as_str(), "https://idp.example.com/sso");
79/// # Ok::<(), saml_rs::SamlError>(())
80/// ```
81#[derive(Debug, Clone)]
82pub struct Outbound<Message> {
83    id: MessageId,
84    relay_state: Option<RelayState>,
85    kind: OutboundKind,
86    redirect_url: Option<String>,
87    post_form: Option<PostForm>,
88    raw_context: BindingContext,
89    _message: PhantomData<Message>,
90}
91
92impl<Message> Outbound<Message> {
93    /// Message ID.
94    pub fn id(&self) -> &MessageId {
95        &self.id
96    }
97
98    /// RelayState parameter, when present.
99    pub fn relay_state(&self) -> Option<&RelayState> {
100        self.relay_state.as_ref()
101    }
102
103    /// Redirect URL for Redirect actions.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`SamlError::UndefinedBinding`] when this action is not Redirect.
108    pub fn redirect_url(&self) -> Result<&str, SamlError> {
109        if self.kind == OutboundKind::Redirect {
110            return self
111                .redirect_url
112                .as_deref()
113                .ok_or_else(|| SamlError::Invalid("missing redirect URL".into()));
114        }
115        Err(SamlError::UndefinedBinding)
116    }
117
118    /// POST form for POST and SimpleSign actions.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`SamlError::UndefinedBinding`] when this action is Redirect.
123    pub fn post_form(&self) -> Result<&PostForm, SamlError> {
124        if matches!(self.kind, OutboundKind::Post | OutboundKind::SimpleSignPost) {
125            return self
126                .post_form
127                .as_ref()
128                .ok_or_else(|| SamlError::Invalid("missing POST form".into()));
129        }
130        Err(SamlError::UndefinedBinding)
131    }
132
133    /// Raw compatibility context used to build this typed action.
134    pub fn raw_context(&self) -> &BindingContext {
135        &self.raw_context
136    }
137
138    /// Consume the typed action and return the raw compatibility context.
139    pub fn into_raw_context(self) -> BindingContext {
140        self.raw_context
141    }
142}
143
144impl TryFrom<BindingContext> for Outbound<AuthnRequest> {
145    type Error = SamlError;
146
147    fn try_from(raw_context: BindingContext) -> Result<Self, Self::Error> {
148        outbound_from_context(raw_context, true, MessageField::Request)
149    }
150}
151
152impl TryFrom<BindingContext> for Outbound<SsoResponse> {
153    type Error = SamlError;
154
155    fn try_from(raw_context: BindingContext) -> Result<Self, Self::Error> {
156        outbound_from_context(raw_context, false, MessageField::Response)
157    }
158}
159
160impl TryFrom<BindingContext> for Outbound<LogoutRequest> {
161    type Error = SamlError;
162
163    fn try_from(raw_context: BindingContext) -> Result<Self, Self::Error> {
164        outbound_from_context(raw_context, true, MessageField::Request)
165    }
166}
167
168impl TryFrom<BindingContext> for Outbound<LogoutResponse> {
169    type Error = SamlError;
170
171    fn try_from(raw_context: BindingContext) -> Result<Self, Self::Error> {
172        outbound_from_context(raw_context, true, MessageField::Response)
173    }
174}
175
176fn outbound_from_context<Message>(
177    raw_context: BindingContext,
178    allow_redirect: bool,
179    expected_message: MessageField,
180) -> Result<Outbound<Message>, SamlError> {
181    validate_context_message_kind(&raw_context, expected_message)?;
182    let id = MessageId::try_new(raw_context.id.clone())?;
183    let relay_state = raw_context
184        .relay_state
185        .clone()
186        .map(RelayState::try_new)
187        .transpose()?;
188    match raw_context.binding {
189        Binding::Redirect => {
190            if !allow_redirect {
191                return Err(SamlError::UndefinedBinding);
192            }
193            EndpointUrl::try_new(raw_context.context.clone())?;
194            validate_redirect_message_kind(&raw_context.context, expected_message)?;
195            Ok(Outbound {
196                id,
197                relay_state,
198                kind: OutboundKind::Redirect,
199                redirect_url: Some(raw_context.context.clone()),
200                post_form: None,
201                raw_context,
202                _message: PhantomData,
203            })
204        }
205        Binding::Post => {
206            reject_detached_signature_for_post(&raw_context)?;
207            let form = post_form_from_context(&raw_context, false)?;
208            Ok(Outbound {
209                id,
210                relay_state,
211                kind: OutboundKind::Post,
212                redirect_url: None,
213                post_form: Some(form),
214                raw_context,
215                _message: PhantomData,
216            })
217        }
218        Binding::SimpleSign => {
219            require_complete_detached_signature(&raw_context)?;
220            let form = post_form_from_context(&raw_context, true)?;
221            Ok(Outbound {
222                id,
223                relay_state,
224                kind: OutboundKind::SimpleSignPost,
225                redirect_url: None,
226                post_form: Some(form),
227                raw_context,
228                _message: PhantomData,
229            })
230        }
231        Binding::Artifact => Err(SamlError::UndefinedBinding),
232    }
233}
234
235fn validate_context_message_kind(
236    context: &BindingContext,
237    expected_message: MessageField,
238) -> Result<(), SamlError> {
239    let expected_name = expected_message.param_name();
240    if context.request_type == expected_name {
241        return Ok(());
242    }
243    Err(SamlError::Invalid(format!(
244        "outbound context request_type must be {expected_name}"
245    )))
246}
247
248fn validate_redirect_message_kind(
249    raw_url: &str,
250    expected_message: MessageField,
251) -> Result<(), SamlError> {
252    let expected_name = expected_message.param_name();
253    let opposite_name = expected_message.opposite_param_name();
254    let parsed = url::Url::parse(raw_url).map_err(|err| SamlError::Invalid(err.to_string()))?;
255    let (expected_count, opposite_count) =
256        parsed
257            .query_pairs()
258            .fold((0usize, 0usize), |(expected, opposite), (name, _)| {
259                if name == expected_name {
260                    (expected + 1, opposite)
261                } else if name == opposite_name {
262                    (expected, opposite + 1)
263                } else {
264                    (expected, opposite)
265                }
266            });
267
268    match (expected_count, opposite_count) {
269        (1, 0) => Ok(()),
270        (0, 0) => Err(SamlError::Invalid(format!(
271            "missing Redirect field {expected_name}"
272        ))),
273        (count, 0) if count > 1 => Err(SamlError::Invalid(format!(
274            "ambiguous Redirect field {expected_name}"
275        ))),
276        (_, _) => Err(SamlError::Invalid(format!(
277            "expected Redirect field {expected_name}, found {opposite_name}"
278        ))),
279    }
280}
281
282fn reject_partial_detached_signature(context: &BindingContext) -> Result<(), SamlError> {
283    if context.sig_alg.is_some() != context.signature.is_some() {
284        return Err(SamlError::Invalid(
285            "partial detached signature state is invalid".into(),
286        ));
287    }
288    Ok(())
289}
290
291fn reject_detached_signature_for_post(context: &BindingContext) -> Result<(), SamlError> {
292    if context.sig_alg.is_some() || context.signature.is_some() {
293        return Err(SamlError::Invalid(
294            "POST outbound must not carry detached signature fields".into(),
295        ));
296    }
297    Ok(())
298}
299
300fn require_complete_detached_signature(context: &BindingContext) -> Result<(), SamlError> {
301    reject_partial_detached_signature(context)?;
302    match (&context.sig_alg, &context.signature) {
303        (Some(_), Some(_)) => Ok(()),
304        _ => Err(SamlError::Invalid(
305            "SimpleSign requires SigAlg and Signature".into(),
306        )),
307    }
308}
309
310fn post_form_from_context(
311    context: &BindingContext,
312    include_signature: bool,
313) -> Result<PostForm, SamlError> {
314    let action = EndpointUrl::try_new(context.entity_endpoint.clone())?;
315    context.try_post_form()?;
316    let mut fields = vec![FormField::new(
317        context.request_type,
318        context.context.clone(),
319    )];
320    if let Some(relay_state) = &context.relay_state {
321        fields.push(FormField::new("RelayState", relay_state.clone()));
322    }
323    if include_signature {
324        fields.push(FormField::new(
325            "SigAlg",
326            context.sig_alg.clone().unwrap_or_default(),
327        ));
328        fields.push(FormField::new(
329            "Signature",
330            context.signature.clone().unwrap_or_default(),
331        ));
332    }
333    Ok(PostForm::new(action, fields))
334}