Skip to main content

saml_rs/
error.rs

1//! Error types for `saml-rs`.
2//!
3//! [`SamlError`] is non-exhaustive. Callers should include a fallback match arm
4//! so new semantic SAML validation failures can be added without breaking
5//! source compatibility.
6
7use crate::constants::Binding;
8use crate::model::RelayStateParam;
9use std::fmt;
10
11/// Reason a required SAML signature verification failed.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[non_exhaustive]
14pub enum SignatureVerificationReason {
15    /// Enveloped XML-DSig verification failed.
16    XmlSignature,
17    /// HTTP-Redirect or SimpleSign detached message signature verification failed.
18    DetachedMessageSignature,
19    /// Detached signature input could not be correlated with consumed RelayState.
20    RelayStateCorrelation,
21    /// Signed reference digest verification failed.
22    ReferenceDigest,
23}
24
25impl fmt::Display for SignatureVerificationReason {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::XmlSignature => f.write_str("xml signature"),
29            Self::DetachedMessageSignature => f.write_str("detached message signature"),
30            Self::RelayStateCorrelation => f.write_str("relay state correlation"),
31            Self::ReferenceDigest => f.write_str("reference digest"),
32        }
33    }
34}
35
36/// Reason a signed XML reference could not be resolved safely.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38#[non_exhaustive]
39pub enum ReferenceResolutionReason {
40    /// Reference URI points outside the same XML document.
41    ExternalReference,
42    /// Reference URI syntax is not supported by the SAML verifier.
43    UnsupportedReferenceUri,
44    /// Signature contained no references to validate.
45    MissingSignatureReference,
46    /// Same-document reference did not resolve to an XML node.
47    UnresolvedReference,
48}
49
50impl fmt::Display for ReferenceResolutionReason {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::ExternalReference => f.write_str("external reference"),
54            Self::UnsupportedReferenceUri => f.write_str("unsupported reference URI"),
55            Self::MissingSignatureReference => f.write_str("missing signature reference"),
56            Self::UnresolvedReference => f.write_str("unresolved reference"),
57        }
58    }
59}
60
61/// Bearer subject confirmation validation failure reason.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63#[non_exhaustive]
64pub enum SubjectConfirmationReason {
65    /// SubjectConfirmation `Method` was missing or was not bearer.
66    InvalidMethod,
67    /// SubjectConfirmationData omitted `NotOnOrAfter`.
68    MissingNotOnOrAfter,
69    /// SubjectConfirmationData `NotOnOrAfter` time bounds were not satisfied.
70    TimeWindowInvalid,
71    /// SubjectConfirmationData `Recipient` did not match the ACS URL.
72    RecipientMismatch,
73    /// SubjectConfirmationData `InResponseTo` did not match the request ID.
74    InResponseToMismatch,
75    /// No bearer SubjectConfirmation satisfied the validation requirements.
76    MissingBearerConfirmation,
77}
78
79impl fmt::Display for SubjectConfirmationReason {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::InvalidMethod => f.write_str("method"),
83            Self::MissingNotOnOrAfter => f.write_str("missing NotOnOrAfter"),
84            Self::TimeWindowInvalid => f.write_str("time window"),
85            Self::RecipientMismatch => f.write_str("recipient"),
86            Self::InResponseToMismatch => f.write_str("InResponseTo"),
87            Self::MissingBearerConfirmation => f.write_str("missing bearer confirmation"),
88        }
89    }
90}
91
92/// SAML time-bound field whose validation failed.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94#[non_exhaustive]
95pub enum TimeWindowField {
96    /// Expiration derived from the typed IdP issuance lifetime could not be
97    /// represented for the current issue instant.
98    IdpIssuanceExpiration,
99    /// LogoutRequest `NotOnOrAfter`.
100    LogoutRequestNotOnOrAfter,
101    /// Assertion session `SessionNotOnOrAfter`.
102    SessionNotOnOrAfter,
103    /// Assertion `Conditions` NotBefore/NotOnOrAfter window.
104    Conditions,
105    /// Replay cache retention window could not be computed or has elapsed.
106    ReplayExpiration,
107    /// AuthnRequest `IssueInstant` age window.
108    AuthnRequestIssueInstant,
109}
110
111impl fmt::Display for TimeWindowField {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::IdpIssuanceExpiration => f.write_str("IdP issuance expiration"),
115            Self::LogoutRequestNotOnOrAfter => f.write_str("LogoutRequest@NotOnOrAfter"),
116            Self::SessionNotOnOrAfter => f.write_str("SessionNotOnOrAfter"),
117            Self::Conditions => f.write_str("Conditions"),
118            Self::ReplayExpiration => f.write_str("ReplayExpiration"),
119            Self::AuthnRequestIssueInstant => f.write_str("AuthnRequest@IssueInstant"),
120        }
121    }
122}
123
124/// Errors produced by the SAML-RS library.
125///
126/// Variants are grouped by validation category:
127///
128/// - wire and XML decoding failures;
129/// - SAML protocol validation failures;
130/// - signature, signed-reference, and delegated crypto failures;
131/// - metadata and trust selection failures;
132/// - configuration, unsupported binding, and compatibility failures.
133///
134/// This enum is `#[non_exhaustive]`; downstream callers should include a
135/// fallback arm when matching.
136#[derive(Debug, thiserror::Error)]
137#[non_exhaustive]
138pub enum SamlError {
139    // Wire / XML errors.
140    /// Raw DEFLATE (de)compression failed.
141    #[error("deflate error: {0}")]
142    Deflate(#[from] std::io::Error),
143    /// Base64 decoding failed.
144    #[error("base64 decode error: {0}")]
145    Base64(#[from] base64::DecodeError),
146    /// Malformed or unexpected XML.
147    #[error("xml error: {0}")]
148    Xml(String),
149    /// Input failed validation.
150    #[error("invalid input: {0}")]
151    Invalid(String),
152    /// XML violates SAML protocol requirements such as QName, version, required
153    /// attributes, or their lexical forms.
154    #[error("SAML protocol profile violation: {0}")]
155    ProtocolProfile(String),
156
157    // Unsupported profiles and bindings.
158    /// Functionality not yet implemented in the current milestone.
159    #[error("unsupported: {0}")]
160    Unsupported(String),
161    /// Requested binding is not supported by this API path.
162    #[error("unsupported binding: {binding:?}")]
163    UnsupportedBinding {
164        /// Binding that reached an unsupported profile path.
165        binding: Binding,
166    },
167
168    // SAML protocol validation.
169    /// Issuer in the message does not match the expected peer entity ID.
170    #[error("issuer mismatch: expected {expected}, got {actual:?}")]
171    IssuerMismatch {
172        /// Expected peer entity ID.
173        expected: String,
174        /// Actual issuer value extracted from the message, when available.
175        actual: Option<String>,
176    },
177    /// Response `Destination` does not match the expected recipient URL.
178    #[error("destination mismatch: expected {expected}, got {actual:?}")]
179    DestinationMismatch {
180        /// Expected recipient URL.
181        expected: String,
182        /// Actual destination value extracted from the message, when available.
183        actual: Option<String>,
184    },
185    /// `InResponseTo` does not match the expected request ID.
186    #[error("inResponseTo mismatch: expected {expected:?}, got {actual:?}")]
187    InResponseToMismatch {
188        /// Expected request ID. `None` means no request correlation was expected.
189        expected: Option<String>,
190        /// Actual `InResponseTo` value extracted from the message, when available.
191        actual: Option<String>,
192    },
193    /// RelayState does not match the pending message correlation state.
194    #[error("relay state mismatch")]
195    RelayStateMismatch {
196        /// Expected RelayState presence and value.
197        expected: RelayStateParam,
198        /// Actual RelayState presence and value.
199        actual: RelayStateParam,
200    },
201    /// `<Audience>` does not include the expected Service Provider entity ID.
202    #[error("audience restriction not satisfied: expected {expected}")]
203    AudienceMismatch {
204        /// Expected SP entity ID.
205        expected: String,
206    },
207    /// A `<Conditions>` child is not one this service provider can evaluate.
208    #[error("assertion condition is not understood: {element}")]
209    UnrecognizedCondition {
210        /// Local name of the unrecognized condition element.
211        element: String,
212    },
213    /// Assertions in one response do not share one principal.
214    ///
215    /// The comparison is `NameID` value, `Format`, `NameQualifier`, and
216    /// `SPNameQualifier`. An omitted `Format` is unspecified.
217    #[error("assertions do not refer to one principal")]
218    PrincipalMismatch,
219    /// Response carried a non-success SAML status code.
220    #[error("status not success: top={top}, second={second:?}")]
221    StatusNotSuccess {
222        /// Top-tier status code.
223        top: String,
224        /// Optional second-tier status code.
225        second: Option<String>,
226    },
227    /// A SAML message, assertion, session, or replay time bound is not satisfied.
228    #[error("SAML time window is invalid for {field}")]
229    TimeWindowInvalid {
230        /// SAML field or validation scope whose time window failed.
231        field: TimeWindowField,
232    },
233    /// Bearer subject confirmation requirements are not satisfied.
234    #[error("subject confirmation is not satisfied: {reason}")]
235    SubjectConfirmationInvalid {
236        /// Stable validation reason for callers and logs.
237        reason: SubjectConfirmationReason,
238    },
239    /// A later assertion matches an outstanding logout that is still before
240    /// `NotOnOrAfter`.
241    #[error("assertion matches an outstanding logout")]
242    AssertionMatchesOutstandingLogout,
243    /// A duplicate SAML message or assertion key was detected.
244    #[error("replayed SAML message or assertion: {key}")]
245    ReplayDetected {
246        /// Replay cache key or duplicate identifier.
247        key: String,
248    },
249
250    // Signature / crypto validation.
251    /// Required signature is absent.
252    #[error("signature missing where required")]
253    SignatureMissing,
254    /// Required binding parameter is absent.
255    #[error("required binding parameter missing: {name}")]
256    MissingBindingParameter {
257        /// Binding parameter name.
258        name: &'static str,
259    },
260    /// Signature verification failed for a semantic SAML validation reason.
261    #[error("signature verification failed: {reason}")]
262    SignatureVerification {
263        /// Stable verification reason for callers and logs.
264        reason: SignatureVerificationReason,
265    },
266    /// Signed reference could not be resolved safely.
267    #[error("signed reference could not be resolved: {reason}")]
268    ReferenceResolution {
269        /// Stable reference-resolution reason for callers and logs.
270        reason: ReferenceResolutionReason,
271    },
272    /// Verified signed reference does not cover the consumed payload.
273    #[error("signed reference does not cover consumed payload")]
274    SignedReferenceMismatch,
275    /// Assertion-signature policy requires direct coverage of the consumed assertion.
276    #[error("consumed assertion is not directly covered by a trusted XML signature")]
277    AssertionSignatureRequired,
278
279    /// An embedded XML signature selected an algorithm outside the strict SAML profile.
280    #[error("embedded XML signature algorithm is not allowed")]
281    AlgorithmUnsupported,
282
283    // Metadata / trust validation.
284    /// No trusted certificate could be selected for verification.
285    #[error("no trusted certificate could be selected for verification")]
286    NoTrustedCertificate,
287    /// Certificate embedded in the message does not match configured metadata.
288    #[error("certificate mismatch")]
289    CertificateMismatch,
290
291    // Legacy compatibility and lower-level operational variants.
292    /// Compatibility variant for issuer validation; prefer [`Self::IssuerMismatch`].
293    #[error("ERR_UNMATCH_ISSUER")]
294    UnmatchIssuer,
295    /// Compatibility variant for audience validation; prefer [`Self::AudienceMismatch`].
296    #[error("ERR_UNMATCH_AUDIENCE")]
297    UnmatchAudience,
298    /// Compatibility variant for destination validation; prefer [`Self::DestinationMismatch`].
299    #[error("ERR_UNMATCH_DESTINATION")]
300    UnmatchDestination,
301    /// Caller supplied a malformed request ID for response correlation.
302    ///
303    /// Prefer [`Self::InResponseToMismatch`] when a message value fails request
304    /// correlation.
305    #[error("ERR_INVALID_IN_RESPONSE_TO")]
306    InvalidInResponseTo,
307    /// Response status code is missing, empty, or could not be extracted.
308    #[error("ERR_UNDEFINED_STATUS")]
309    UndefinedStatus,
310    /// Compatibility variant for non-success status; prefer [`Self::StatusNotSuccess`].
311    #[error("ERR_FAILED_STATUS with top tier code: {top}, second tier code: {second}")]
312    FailedStatus {
313        /// Top-tier status code.
314        top: String,
315        /// Second-tier status code (empty when absent).
316        second: String,
317    },
318    /// Compatibility variant for elapsed session bounds; prefer [`Self::TimeWindowInvalid`].
319    #[error("ERR_EXPIRED_SESSION")]
320    ExpiredSession,
321    /// Compatibility variant for subject confirmation failures; prefer
322    /// [`Self::SubjectConfirmationInvalid`].
323    #[error("ERR_SUBJECT_UNCONFIRMED")]
324    SubjectUnconfirmed,
325    /// A signature-wrapping (XSW) attempt was detected.
326    #[error("ERR_POTENTIAL_WRAPPING_ATTACK")]
327    PotentialWrappingAttack,
328    /// Compatibility variant for missing binding signature parameters; prefer
329    /// [`Self::SignatureMissing`] or [`Self::MissingBindingParameter`].
330    #[error("ERR_MISSING_SIG_ALG")]
331    MissingSigAlg,
332    /// Compatibility variant for detached signature failures; prefer
333    /// [`Self::SignatureVerification`].
334    #[error("ERR_FAILED_MESSAGE_SIGNATURE_VERIFICATION")]
335    FailedMessageSignatureVerification,
336    /// Compatibility variant for XML-DSig failures; prefer [`Self::SignatureVerification`].
337    #[error("FAILED_TO_VERIFY_SIGNATURE")]
338    FailedToVerifySignature,
339    /// Compatibility variant for certificate mismatch; prefer [`Self::CertificateMismatch`].
340    #[error("ERROR_UNMATCH_CERTIFICATE_DECLARATION_IN_METADATA")]
341    UnmatchCertificate,
342    /// Compatibility variant for unsupported bindings; prefer [`Self::UnsupportedBinding`].
343    #[error("ERR_UNDEFINED_BINDING")]
344    UndefinedBinding,
345    /// Required metadata (endpoint/certificate) was missing.
346    #[error("missing metadata: {0}")]
347    MissingMetadata(String),
348    /// A required cryptographic key was missing.
349    #[error("missing key: {0}")]
350    MissingKey(String),
351    /// A delegated cryptographic operation failed.
352    #[error("crypto error: {0}")]
353    Crypto(String),
354}
355
356impl SamlError {
357    pub(crate) fn issuer_mismatch(expected: &str, actual: Option<&str>) -> Self {
358        Self::IssuerMismatch {
359            expected: expected.to_string(),
360            actual: actual.map(str::to_string),
361        }
362    }
363
364    pub(crate) fn destination_mismatch(expected: &str, actual: Option<&str>) -> Self {
365        Self::DestinationMismatch {
366            expected: expected.to_string(),
367            actual: actual.map(str::to_string),
368        }
369    }
370
371    pub(crate) fn in_response_to_mismatch(expected: Option<&str>, actual: Option<&str>) -> Self {
372        Self::InResponseToMismatch {
373            expected: expected.map(str::to_string),
374            actual: actual.map(str::to_string),
375        }
376    }
377}