seamless_auth/
manifest.rs1use 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
9pub 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#[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#[derive(Clone, Debug, Deserialize)]
31#[non_exhaustive]
32pub struct ManifestRoute {
33 pub method: String,
34 pub path: String,
35 pub credential: String,
38 #[serde(default)]
41 pub issues: Option<String>,
42 #[serde(default)]
44 pub clears: Vec<String>,
45 #[serde(default)]
47 pub body: Option<PickBody>,
48 #[serde(default)]
50 pub delivery: bool,
51}
52
53#[derive(Clone, Debug, Deserialize)]
55#[non_exhaustive]
56pub struct PickBody {
57 pub pick: Vec<String>,
58}
59
60#[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 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 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 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 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 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
208pub(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 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
231fn 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
245pub(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}