Skip to main content

seamless_auth/
manifest.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::{Arc, OnceLock};
4use std::time::{Duration, Instant};
5
6use serde::Deserialize;
7use tokio::sync::Mutex;
8
9/// Where the auth API publishes which token each route takes and which tokens
10/// its response issues or clears.
11pub const MANIFEST_PATH: &str = "/.well-known/seamless-adapter.json";
12
13const BUNDLED_MANIFEST: &str = include_str!("../manifest.json");
14const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
15const RETRY_AFTER: Duration = Duration::from_secs(60);
16const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
17
18/// The auth API's adapter manifest (`schemaVersion` 1).
19#[derive(Clone, Debug, Deserialize)]
20#[serde(rename_all = "camelCase")]
21#[non_exhaustive]
22pub struct Manifest {
23    pub schema_version: u32,
24    #[serde(default)]
25    pub api_version: String,
26    pub routes: Vec<ManifestRoute>,
27}
28
29/// One route an adapter serves.
30#[derive(Clone, Debug, Deserialize)]
31#[non_exhaustive]
32pub struct ManifestRoute {
33    pub method: String,
34    pub path: String,
35    /// The held token the route takes: `none`, `preAuth`, `registration`,
36    /// `access` or `refresh`.
37    pub credential: String,
38    /// The tokens its response issues: `preAuth`, `registration`, `session` or
39    /// `access`.
40    #[serde(default)]
41    pub issues: Option<String>,
42    /// The held tokens its response ends.
43    #[serde(default)]
44    pub clears: Vec<String>,
45    /// The only fields a browser sees, when set.
46    #[serde(default)]
47    pub body: Option<PickBody>,
48    /// Whether the route sends an OTP code or magic link.
49    #[serde(default)]
50    pub delivery: bool,
51}
52
53/// The fields of a response body a browser may see.
54#[derive(Clone, Debug, Deserialize)]
55#[non_exhaustive]
56pub struct PickBody {
57    pub pick: Vec<String>,
58}
59
60/// Why a manifest was refused.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ManifestError(String);
63
64impl fmt::Display for ManifestError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(&self.0)
67    }
68}
69
70impl std::error::Error for ManifestError {}
71
72const KNOWN_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
73const KNOWN_CREDENTIALS: &[&str] = &["none", "preAuth", "registration", "access", "refresh"];
74const KNOWN_ISSUES: &[&str] = &["preAuth", "registration", "session", "access"];
75const KNOWN_HELD: &[&str] = &["preAuth", "registration", "access", "refresh"];
76
77impl Manifest {
78    /// Accepts a manifest only if every route is one this crate knows how to
79    /// follow. A route with a credential or effect it does not understand could
80    /// be proxied with the wrong token, so such a manifest is refused whole.
81    pub fn parse(data: &[u8]) -> Result<Manifest, ManifestError> {
82        let mut manifest: Manifest =
83            serde_json::from_slice(data).map_err(|e| ManifestError(e.to_string()))?;
84        if manifest.schema_version != 1 {
85            return Err(ManifestError(format!(
86                "unsupported manifest schemaVersion {}",
87                manifest.schema_version
88            )));
89        }
90        for route in &mut manifest.routes {
91            if route.issues.as_deref() == Some("") {
92                route.issues = None;
93            }
94            let known = KNOWN_METHODS.contains(&route.method.as_str())
95                && route.path.starts_with('/')
96                && KNOWN_CREDENTIALS.contains(&route.credential.as_str())
97                && route
98                    .issues
99                    .as_deref()
100                    .is_none_or(|i| KNOWN_ISSUES.contains(&i))
101                && route
102                    .clears
103                    .iter()
104                    .all(|held| KNOWN_HELD.contains(&held.as_str()));
105            if !known {
106                return Err(ManifestError(format!(
107                    "unsupported manifest route {} {}",
108                    route.method, route.path
109                )));
110            }
111        }
112        Ok(manifest)
113    }
114
115    /// The manifest this version of the crate was built against.
116    pub fn bundled() -> Arc<Manifest> {
117        static BUNDLED: OnceLock<Arc<Manifest>> = OnceLock::new();
118        BUNDLED
119            .get_or_init(|| {
120                Arc::new(
121                    Manifest::parse(BUNDLED_MANIFEST.as_bytes()).unwrap_or_else(|e| {
122                        panic!("seamless-auth: bundled manifest is invalid: {e}")
123                    }),
124                )
125            })
126            .clone()
127    }
128
129    /// Finds the route for a request. Static segments compare
130    /// case-insensitively, and a static segment beats a parameter at the same
131    /// position, so `/admin/users/import` is never read as `/admin/users/{userId}`.
132    pub(crate) fn find(&self, method: &str, path: &str) -> Option<RouteMatch<'_>> {
133        let requested = segments(path);
134        let mut best: Option<(RouteMatch<'_>, usize)> = None;
135
136        for route in &self.routes {
137            if !route.method.eq_ignore_ascii_case(method) {
138                continue;
139            }
140            let pattern = segments(&route.path);
141            if pattern.len() != requested.len() {
142                continue;
143            }
144
145            let mut params = HashMap::new();
146            let mut score = 0;
147            let mut matched = true;
148
149            for (part, value) in pattern.iter().zip(&requested) {
150                if let Some(name) = param_name(part) {
151                    match percent_decode(value) {
152                        // Re-encoded, a dot segment survives as a literal ".." that
153                        // the HTTP client resolves, sending the held token to another
154                        // upstream path.
155                        Some(decoded) if decoded != "." && decoded != ".." => {
156                            params.insert(name.to_string(), decoded);
157                        }
158                        _ => {
159                            matched = false;
160                            break;
161                        }
162                    }
163                    continue;
164                }
165                if !part.eq_ignore_ascii_case(value) {
166                    matched = false;
167                    break;
168                }
169                score += 1;
170            }
171
172            if matched && best.as_ref().is_none_or(|(_, s)| score > *s) {
173                best = Some((RouteMatch { route, params }, score));
174            }
175        }
176
177        best.map(|(m, _)| m)
178    }
179}
180
181pub(crate) struct RouteMatch<'a> {
182    pub route: &'a ManifestRoute,
183    pub params: HashMap<String, String>,
184}
185
186impl ManifestRoute {
187    /// The upstream path, with each `{param}` segment filled and escaped.
188    pub(crate) fn upstream_path(&self, params: &HashMap<String, String>) -> String {
189        self.path
190            .split('/')
191            .map(|part| match param_name(part) {
192                Some(name) => percent_encode(params.get(name).map(String::as_str).unwrap_or("")),
193                None => part.to_string(),
194            })
195            .collect::<Vec<_>>()
196            .join("/")
197    }
198}
199
200fn param_name(part: &str) -> Option<&str> {
201    part.strip_prefix('{').and_then(|p| p.strip_suffix('}'))
202}
203
204fn segments(path: &str) -> Vec<&str> {
205    path.split('/').filter(|s| !s.is_empty()).collect()
206}
207
208/// Decodes a path segment. Refuses a malformed escape or a result that is not
209/// UTF-8, as Go's `url.PathUnescape` does.
210pub(crate) fn percent_decode(segment: &str) -> Option<String> {
211    let bytes = segment.as_bytes();
212    let mut out = Vec::with_capacity(bytes.len());
213    let mut i = 0;
214    while i < bytes.len() {
215        if bytes[i] == b'%' {
216            let hex = bytes.get(i + 1..i + 3)?;
217            // from_str_radix alone would take "+1".
218            if !hex.iter().all(u8::is_ascii_hexdigit) {
219                return None;
220            }
221            out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
222            i += 3;
223        } else {
224            out.push(bytes[i]);
225            i += 1;
226        }
227    }
228    String::from_utf8(out).ok()
229}
230
231/// Escapes a value for one path segment, leaving alone exactly what Go's
232/// `url.PathEscape` does.
233fn percent_encode(value: &str) -> String {
234    let mut out = String::with_capacity(value.len());
235    for b in value.bytes() {
236        if b.is_ascii_alphanumeric() || b"-._~$&+:=@".contains(&b) {
237            out.push(b as char);
238        } else {
239            out.push_str(&format!("%{b:02X}"));
240        }
241    }
242    out
243}
244
245/// Fetches the live manifest once and keeps it, falling back to the bundled
246/// copy. A failed fetch is retried a minute later, not on every request.
247pub(crate) struct ManifestSource {
248    url: String,
249    client: reqwest::Client,
250    fetch: bool,
251    state: Mutex<SourceState>,
252}
253
254#[derive(Default)]
255struct SourceState {
256    loaded: Option<Arc<Manifest>>,
257    retry_at: Option<Instant>,
258}
259
260impl ManifestSource {
261    pub fn new(auth_server_url: &str, client: reqwest::Client, fetch: bool) -> Self {
262        ManifestSource {
263            url: format!("{auth_server_url}{MANIFEST_PATH}"),
264            client,
265            fetch,
266            state: Mutex::new(SourceState::default()),
267        }
268    }
269
270    pub async fn get(&self) -> Arc<Manifest> {
271        if !self.fetch {
272            return Manifest::bundled();
273        }
274
275        let mut state = self.state.lock().await;
276        if let Some(loaded) = &state.loaded {
277            return loaded.clone();
278        }
279        if state.retry_at.is_some_and(|at| Instant::now() < at) {
280            return Manifest::bundled();
281        }
282
283        match self.load().await {
284            Ok(manifest) => {
285                let manifest = Arc::new(manifest);
286                state.loaded = Some(manifest.clone());
287                manifest
288            }
289            Err(err) => {
290                tracing::warn!(
291                    "[seamless-auth] Could not load the adapter manifest ({err}). Using the bundled copy."
292                );
293                state.retry_at = Some(Instant::now() + RETRY_AFTER);
294                Manifest::bundled()
295            }
296        }
297    }
298
299    async fn load(&self) -> Result<Manifest, String> {
300        let res = self
301            .client
302            .get(&self.url)
303            .header("accept", "application/json")
304            .timeout(FETCH_TIMEOUT)
305            .send()
306            .await
307            .map_err(|e| e.to_string())?;
308        if res.status() != reqwest::StatusCode::OK {
309            return Err(format!("HTTP {}", res.status().as_u16()));
310        }
311        let body = crate::adapter::read_limited(res, MAX_MANIFEST_BYTES).await?;
312        Manifest::parse(&body).map_err(|e| e.to_string())
313    }
314}