Skip to main content

seamless_auth/
console.rs

1use std::time::Duration;
2
3use axum::Router;
4use axum::body::Body;
5use axum::extract::Request;
6use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LAST_MODIFIED};
7use axum::http::{HeaderMap, Method, StatusCode};
8use axum::response::Response;
9use axum::routing::any;
10use reqwest::Url;
11use reqwest::redirect::Policy;
12use serde_json::json;
13
14use crate::adapter::{Adapter, Inner, json_response};
15use crate::manifest::percent_decode;
16
17const CONSOLE_BASE_PATH: &str = "/console";
18const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10);
19const MAX_REDIRECTS: usize = 5;
20
21/// The auth API's origin and the console's path on it.
22#[derive(Clone)]
23struct ConsoleOrigin {
24    base: Url,
25    prefix: String,
26}
27
28impl ConsoleOrigin {
29    fn new(auth_server_url: &str) -> Option<Self> {
30        let base = Url::parse(auth_server_url).ok()?;
31        base.host_str()?;
32        let prefix = format!("{}{CONSOLE_BASE_PATH}", base.path().trim_end_matches('/'));
33        Some(ConsoleOrigin { base, prefix })
34    }
35
36    /// Holds a URL to the auth API's origin and the console subtree.
37    fn contains(&self, url: &Url) -> bool {
38        url.scheme() == self.base.scheme()
39            && url.host_str() == self.base.host_str()
40            && url.port_or_known_default() == self.base.port_or_known_default()
41            && (url.path() == self.prefix || url.path().starts_with(&format!("{}/", self.prefix)))
42    }
43
44    /// Maps a path below the mount to the console on the auth API, refusing
45    /// anything that could leave the console subtree: an encoded separator (an
46    /// upstream that decodes it would read a traversal), and any dot segment,
47    /// literal or encoded.
48    fn upstream(&self, subpath: &str, query: Option<&str>) -> Option<Url> {
49        let lower = subpath.to_ascii_lowercase();
50        if lower.contains("%2f") || lower.contains("%5c") || subpath.contains('\\') {
51            return None;
52        }
53        for segment in subpath.split('/') {
54            match percent_decode(segment) {
55                Some(decoded) if decoded != "." && decoded != ".." => {}
56                _ => return None,
57            }
58        }
59
60        let suffix = match subpath {
61            "" | "/" => String::new(),
62            s if s.starts_with('/') => s.to_string(),
63            s => format!("/{s}"),
64        };
65        let mut url = self.base.clone();
66        url.set_path(&format!("{}{suffix}", self.prefix));
67        url.set_query(query.filter(|q| !q.is_empty()));
68        self.contains(&url).then_some(url)
69    }
70}
71
72impl Adapter {
73    /// Reverse-proxies the Seamless admin dashboard from the auth API, so it loads
74    /// from the same origin as the cookie-based auth routes. It serves `/console`,
75    /// the path the dashboard is built against, so merge it rather than nest it:
76    /// `Router::new().merge(auth.console_router())`. (A router nested at
77    /// `/console` never sees `/console/`, where the browser lands.)
78    ///
79    /// Nothing from the incoming request is forwarded but the method and the
80    /// path: the console is public static hosting, and the browser's session
81    /// cookies have no business at the upstream.
82    pub fn console_router<S>(&self) -> Router<S>
83    where
84        S: Clone + Send + Sync + 'static,
85    {
86        let inner = self.inner.clone();
87        let handler = move |req: Request| {
88            let inner = inner.clone();
89            async move { inner.serve_console(req).await }
90        };
91        Router::new()
92            .route(CONSOLE_BASE_PATH, any(handler.clone()))
93            .route(&format!("{CONSOLE_BASE_PATH}/"), any(handler.clone()))
94            .route(&format!("{CONSOLE_BASE_PATH}/{{*path}}"), any(handler))
95    }
96}
97
98impl Inner {
99    /// Its own client: the adapter's follows no redirects at all, and the
100    /// console follows them while they stay inside it.
101    fn console_client(&self, origin: &ConsoleOrigin) -> Option<reqwest::Client> {
102        self.console_client
103            .get_or_init(|| {
104                let origin = origin.clone();
105                reqwest::Client::builder()
106                    .timeout(UPSTREAM_TIMEOUT)
107                    .redirect(Policy::custom(move |attempt| {
108                        if attempt.previous().len() >= MAX_REDIRECTS {
109                            attempt.error("too many redirects")
110                        } else if origin.contains(attempt.url()) {
111                            attempt.follow()
112                        } else {
113                            attempt.error("redirect leaves the console")
114                        }
115                    }))
116                    .build()
117                    .ok()
118            })
119            .clone()
120    }
121
122    async fn serve_console(&self, req: Request) -> Response {
123        let method = req.method().clone();
124        if method != Method::GET && method != Method::HEAD {
125            return json_response(
126                StatusCode::METHOD_NOT_ALLOWED,
127                &json!({ "error": "Method not allowed" }),
128            );
129        }
130
131        let invalid = || {
132            json_response(
133                StatusCode::BAD_REQUEST,
134                &json!({ "error": "Invalid console path" }),
135            )
136        };
137        let Some(origin) = ConsoleOrigin::new(&self.config.auth_server_url) else {
138            return invalid();
139        };
140        let subpath = req
141            .uri()
142            .path()
143            .strip_prefix(CONSOLE_BASE_PATH)
144            .unwrap_or_default();
145        let Some(upstream) = origin.upstream(subpath, req.uri().query()) else {
146            return invalid();
147        };
148        let Some(client) = self.console_client(&origin) else {
149            return json_response(
150                StatusCode::BAD_GATEWAY,
151                &json!({ "error": "Console upstream unreachable" }),
152            );
153        };
154
155        let res = match client.request(method.clone(), upstream).send().await {
156            Ok(res) => res,
157            Err(_) => {
158                return json_response(
159                    StatusCode::BAD_GATEWAY,
160                    &json!({ "error": "Console upstream unreachable" }),
161                );
162            }
163        };
164
165        let mut headers = HeaderMap::new();
166        for name in [CONTENT_TYPE, CACHE_CONTROL, ETAG, LAST_MODIFIED] {
167            if let Some(value) = res.headers().get(&name) {
168                headers.insert(name, value.clone());
169            }
170        }
171        let status = res.status();
172        let body = if method == Method::HEAD {
173            Body::empty()
174        } else {
175            Body::new(reqwest::Body::from(res))
176        };
177        let mut response = Response::new(body);
178        *response.status_mut() = status;
179        *response.headers_mut() = headers;
180        response
181    }
182}