Skip to main content

saml_rs/model/
authn.rs

1use super::extract::{
2    name_id_format_from_uri, name_id_policy_from_extract, optional_endpoint, optional_u16,
3    required_str, subject_confirmations_at,
4};
5use super::identifiers::{MessageId, SamlInstant};
6use super::subject::{NameId, NameIdPolicy, RequestedSubject, RequestedSubjectIdentifier};
7use super::{EndpointUrl, ReplayKey, SamlValidationContext};
8use crate::browser::SsoResponseBinding;
9use crate::config::EntityId;
10use crate::constants::Binding;
11use crate::error::SamlError;
12use crate::raw::FlowResult;
13use crate::xml::parse_saml_utc_date_time;
14
15/// `ForceAuthn` on an `<AuthnRequest>` (Core §3.4.1).
16///
17/// [`Self::Required`] is `true`. [`Self::NotRequired`] is `false`. Omission
18/// leaves [`AuthnRequest::force_authn`] as `None`. When this and [`IsPassive`]
19/// are both required, do not freshly authenticate the presenter unless
20/// `IsPassive` can be met.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ForceAuthn {
23    /// `ForceAuthn="true"`.
24    Required,
25    /// `ForceAuthn="false"`.
26    NotRequired,
27}
28
29impl ForceAuthn {
30    pub(crate) fn from_bool(value: bool) -> Self {
31        if value {
32            Self::Required
33        } else {
34            Self::NotRequired
35        }
36    }
37
38    pub(crate) fn as_bool(self) -> bool {
39        match self {
40            Self::Required => true,
41            Self::NotRequired => false,
42        }
43    }
44}
45
46/// `IsPassive` on an `<AuthnRequest>` (Core §3.4.1).
47///
48/// [`Self::Required`] is `true`: do not take visible control of the user
49/// interface. [`Self::NotRequired`] is `false`. Omission leaves
50/// [`AuthnRequest::is_passive`] as `None`.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum IsPassive {
53    /// `IsPassive="true"`.
54    Required,
55    /// `IsPassive="false"`.
56    NotRequired,
57}
58
59impl IsPassive {
60    pub(crate) fn from_bool(value: bool) -> Self {
61        if value {
62            Self::Required
63        } else {
64            Self::NotRequired
65        }
66    }
67}
68
69/// Parsed AuthnRequest result.
70#[derive(Debug, Clone)]
71pub struct AuthnRequest {
72    id: MessageId,
73    issue_instant: SamlInstant,
74    issuer: EntityId,
75    destination: Option<EndpointUrl>,
76    acs_url: Option<EndpointUrl>,
77    protocol_binding: Option<SsoResponseBinding>,
78    acs_index: Option<u16>,
79    name_id_policy: Option<NameIdPolicy>,
80    force_authn: Option<ForceAuthn>,
81    is_passive: Option<IsPassive>,
82    requested_subject: Option<RequestedSubject>,
83    raw_flow: FlowResult,
84}
85
86impl AuthnRequest {
87    /// Request ID.
88    pub fn id(&self) -> &MessageId {
89        &self.id
90    }
91
92    /// Request `IssueInstant`, normalized according to XML Schema whitespace rules.
93    pub fn issue_instant(&self) -> &SamlInstant {
94        &self.issue_instant
95    }
96
97    /// Request issuer.
98    pub fn issuer(&self) -> &EntityId {
99        &self.issuer
100    }
101
102    /// Destination endpoint, when present.
103    pub fn destination(&self) -> Option<&EndpointUrl> {
104        self.destination.as_ref()
105    }
106
107    /// AssertionConsumerServiceURL, when present.
108    pub fn acs_url(&self) -> Option<&EndpointUrl> {
109        self.acs_url.as_ref()
110    }
111
112    /// Requested response `ProtocolBinding`, when present.
113    pub fn protocol_binding(&self) -> Option<SsoResponseBinding> {
114        self.protocol_binding
115    }
116
117    /// Requested `AssertionConsumerServiceIndex`, when present.
118    pub fn acs_index(&self) -> Option<u16> {
119        self.acs_index
120    }
121
122    /// NameIDPolicy, when present.
123    pub fn name_id_policy(&self) -> Option<&NameIdPolicy> {
124        self.name_id_policy.as_ref()
125    }
126
127    /// `ForceAuthn`, when set. See [`ForceAuthn`].
128    pub fn force_authn(&self) -> Option<ForceAuthn> {
129        self.force_authn
130    }
131
132    /// `IsPassive`, when set. See [`IsPassive`].
133    pub fn is_passive(&self) -> Option<IsPassive> {
134        self.is_passive
135    }
136
137    /// Requested `<Subject>`, when present. See [`RequestedSubject`].
138    pub fn requested_subject(&self) -> Option<&RequestedSubject> {
139        self.requested_subject.as_ref()
140    }
141
142    /// Check and store this AuthnRequest's replay key using caller cache state.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`SamlError::ReplayDetected`] when the request ID has already
147    /// been seen. Cache implementations may also return storage-specific
148    /// failures mapped to [`SamlError`].
149    pub fn check_and_store_replay(
150        &self,
151        validation: &mut SamlValidationContext<'_>,
152    ) -> Result<(), SamlError> {
153        validation.check_and_store_message_replay(ReplayKey::AuthnRequestId(self.id.clone()))
154    }
155
156    /// Raw validated flow result.
157    pub fn raw_flow(&self) -> &FlowResult {
158        &self.raw_flow
159    }
160}
161
162impl TryFrom<FlowResult> for AuthnRequest {
163    type Error = SamlError;
164
165    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
166        let id = MessageId::try_new(required_str(&raw_flow.extract, "request.id")?)?;
167        let issue_instant = issue_instant_from_extract(&raw_flow.extract)?;
168        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
169        let destination = optional_endpoint(&raw_flow.extract, "request.destination")?;
170        let acs_url = optional_endpoint(&raw_flow.extract, "request.assertionConsumerServiceUrl")?;
171        let protocol_binding = optional_response_binding(&raw_flow.extract)?;
172        let acs_index = optional_u16(&raw_flow.extract, "request.assertionConsumerServiceIndex")?;
173        let name_id_policy = name_id_policy_from_extract(&raw_flow.extract)?;
174        let force_authn = optional_flag(&raw_flow.extract, "request.forceAuthn", "ForceAuthn")?
175            .map(ForceAuthn::from_bool);
176        let is_passive = optional_flag(&raw_flow.extract, "request.isPassive", "IsPassive")?
177            .map(IsPassive::from_bool);
178        let requested_subject = requested_subject_from_extract(&raw_flow.extract)?;
179        Ok(Self {
180            id,
181            issue_instant,
182            issuer,
183            destination,
184            acs_url,
185            protocol_binding,
186            acs_index,
187            name_id_policy,
188            force_authn,
189            is_passive,
190            requested_subject,
191            raw_flow,
192        })
193    }
194}
195
196fn optional_flag(
197    extract: &crate::util::Value,
198    path: &str,
199    label: &str,
200) -> Result<Option<bool>, SamlError> {
201    let Some(value) = extract.get_str(path) else {
202        return Ok(None);
203    };
204    match value {
205        "true" | "1" => Ok(Some(true)),
206        "false" | "0" => Ok(Some(false)),
207        _ => Err(SamlError::Invalid(format!(
208            "AuthnRequest {label} must be an XML Schema boolean (true, false, 1, or 0)"
209        ))),
210    }
211}
212
213fn requested_subject_from_extract(
214    extract: &crate::util::Value,
215) -> Result<Option<RequestedSubject>, SamlError> {
216    if single_text(extract, "requestedSubject", "AuthnRequest Subject")?.is_none() {
217        return Ok(None);
218    }
219    let identifier = requested_identifier(extract)?;
220    let confirmations = subject_confirmations_at(extract, "requestedSubjectConfirmation");
221    Ok(Some(RequestedSubject::new(identifier, confirmations)))
222}
223
224fn requested_identifier(
225    extract: &crate::util::Value,
226) -> Result<RequestedSubjectIdentifier, SamlError> {
227    let name_id = single_text(extract, "requestedNameId", "AuthnRequest NameID")?;
228    let base_id = single_text(extract, "requestedBaseId", "AuthnRequest BaseID")?;
229    let encrypted_id = single_text(extract, "requestedEncryptedId", "AuthnRequest EncryptedID")?;
230    let identifiers = [name_id, base_id, encrypted_id]
231        .into_iter()
232        .filter(Option::is_some)
233        .count();
234    if identifiers > 1 {
235        return Err(SamlError::Invalid(
236            "AuthnRequest Subject must contain one identifier element".into(),
237        ));
238    }
239    if let Some(value) = name_id {
240        return Ok(RequestedSubjectIdentifier::NameId(name_id_from_extract(
241            extract, value,
242        )?));
243    }
244    if base_id.is_some() {
245        return Ok(RequestedSubjectIdentifier::BaseId);
246    }
247    if encrypted_id.is_some() {
248        return Ok(RequestedSubjectIdentifier::EncryptedId);
249    }
250    Ok(RequestedSubjectIdentifier::NoIdentifier)
251}
252
253fn name_id_from_extract(extract: &crate::util::Value, value: &str) -> Result<NameId, SamlError> {
254    let format = single_text(
255        extract,
256        "requestedNameIdFormat",
257        "AuthnRequest NameID Format",
258    )?
259    .map(name_id_format_from_uri);
260    Ok(NameId::with_qualifiers(
261        value.to_string(),
262        format,
263        optional_attribute(extract, "requestedNameQualifier", "NameQualifier")?,
264        optional_attribute(extract, "requestedSpNameQualifier", "SPNameQualifier")?,
265        optional_attribute(extract, "requestedSpProvidedId", "SPProvidedID")?,
266    ))
267}
268
269fn optional_attribute(
270    extract: &crate::util::Value,
271    path: &str,
272    label: &str,
273) -> Result<Option<String>, SamlError> {
274    Ok(single_text(extract, path, label)?.map(str::to_string))
275}
276
277fn single_text<'a>(
278    extract: &'a crate::util::Value,
279    path: &str,
280    label: &str,
281) -> Result<Option<&'a str>, SamlError> {
282    match extract.get(path) {
283        None | Some(crate::util::Value::Null) => Ok(None),
284        Some(crate::util::Value::Str(value)) => Ok(Some(value.as_str())),
285        Some(crate::util::Value::Array(_) | crate::util::Value::Object(_)) => {
286            Err(SamlError::Invalid(format!("{label} must occur once")))
287        }
288    }
289}
290
291fn issue_instant_from_extract(extract: &crate::util::Value) -> Result<SamlInstant, SamlError> {
292    let issue_instant = extract.get_str("request.issueInstant").ok_or_else(|| {
293        SamlError::ProtocolProfile(
294            "AuthnRequest is missing required unqualified attribute IssueInstant".into(),
295        )
296    })?;
297    let issue_instant = parse_saml_utc_date_time(issue_instant).ok_or_else(|| {
298        SamlError::ProtocolProfile(
299            "AuthnRequest IssueInstant must use the SAML-conformant UTC xs:dateTime form ending in Z"
300                .into(),
301        )
302    })?;
303    SamlInstant::try_new(issue_instant)
304}
305
306fn optional_response_binding(
307    extract: &crate::util::Value,
308) -> Result<Option<SsoResponseBinding>, SamlError> {
309    let Some(protocol_binding) = extract.get_str("request.protocolBinding") else {
310        return Ok(None);
311    };
312    let binding = Binding::from_urn(protocol_binding).ok_or_else(|| {
313        SamlError::Invalid(format!(
314            "unsupported AuthnRequest ProtocolBinding {protocol_binding}"
315        ))
316    })?;
317    SsoResponseBinding::try_from(binding).map(Some)
318}