Skip to main content

r402_extensions/siwx/
origin.rs

1//! Configured public origin for SIWX challenges.
2//!
3//! Domain and URI are derived from this value. They are never taken from the
4//! HTTP `Host` header.
5
6use compact_str::CompactString;
7use url::Url;
8
9/// Failed to parse a configured public origin.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
11#[non_exhaustive]
12pub enum SiwxOriginError {
13    /// Origin string was empty.
14    #[error("siwx origin is required")]
15    Missing,
16    /// Origin is not an absolute `http://` or `https://` URL.
17    ///
18    /// A bare host (the shape of an HTTP `Host` header) is rejected.
19    #[error("siwx origin must be an absolute http(s) URL")]
20    NotAbsolute,
21    /// Authority is empty or contains userinfo.
22    #[error("siwx origin authority is invalid")]
23    InvalidAuthority,
24}
25
26/// Absolute public origin used as the SIWX trust anchor.
27///
28/// Constructed from server configuration. There is no constructor that
29/// accepts an HTTP `Host` header.
30///
31/// Host is lowercased. Default ports (`https` 443, `http` 80) are stripped,
32/// matching `URL.origin` / `URL.host`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SiwxOrigin {
35    origin: CompactString,
36    domain: CompactString,
37}
38
39impl SiwxOrigin {
40    /// Parses an absolute `http://` or `https://` origin.
41    ///
42    /// Path, query, and fragment are discarded. Userinfo is rejected.
43    ///
44    /// # Errors
45    ///
46    /// [`SiwxOriginError`] when the value is missing, not absolute http(s),
47    /// or has an empty/userinfo authority.
48    pub fn parse(raw: &str) -> Result<Self, SiwxOriginError> {
49        let trimmed = raw.trim();
50        if trimmed.is_empty() {
51            return Err(SiwxOriginError::Missing);
52        }
53        let parsed = Url::parse(trimmed).map_err(|_| SiwxOriginError::NotAbsolute)?;
54        if parsed.scheme() != "http" && parsed.scheme() != "https" {
55            return Err(SiwxOriginError::NotAbsolute);
56        }
57        if !parsed.username().is_empty() || parsed.password().is_some() {
58            return Err(SiwxOriginError::InvalidAuthority);
59        }
60        if parsed.host_str().is_none_or(str::is_empty) {
61            return Err(SiwxOriginError::InvalidAuthority);
62        }
63        let serialized = parsed.origin().ascii_serialization();
64        let domain = serialized
65            .strip_prefix("https://")
66            .or_else(|| serialized.strip_prefix("http://"))
67            .ok_or(SiwxOriginError::NotAbsolute)?;
68        Ok(Self {
69            origin: CompactString::from(serialized.as_str()),
70            domain: CompactString::from(domain),
71        })
72    }
73
74    /// Scheme + host + optional non-default port, with no path.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.origin
78    }
79
80    /// CAIP-122 `domain` (host and non-default port). Never from `Host`.
81    #[must_use]
82    pub fn domain(&self) -> &str {
83        &self.domain
84    }
85
86    /// Resource URI: configured origin joined with `path`.
87    #[must_use]
88    pub fn uri(&self, path: &str) -> CompactString {
89        join_origin_path(&self.origin, path)
90    }
91
92    /// Paid-address store key: configured origin + request path.
93    #[must_use]
94    pub fn store_key(&self, path: &str) -> CompactString {
95        join_origin_path(&self.origin, path)
96    }
97}
98
99fn join_origin_path(origin: &str, path: &str) -> CompactString {
100    let path = if path.is_empty() { "/" } else { path };
101    let mut out = CompactString::from(origin);
102    if !path.starts_with('/') {
103        out.push('/');
104    }
105    out.push_str(path);
106    out
107}