From f0d31bc6fcdc306a323c12f5c8512068c6f30597 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 8 Oct 2026 15:04:17 -0400 Subject: [PATCH] feat: serve the admin console through console_router Proxies the Seamless admin dashboard from the auth API at /console, as the Express and Fastify adapters do, so an Axum API can serve it on its own origin. GET and HEAD only, nothing forwarded but the method and path, no path that can leave the console subtree, and redirects followed only within it. --- AGENTS.md | 1 + README.md | 18 +++++ src/adapter.rs | 2 + src/console.rs | 182 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/manifest.rs | 2 +- src/tests/console.rs | 170 ++++++++++++++++++++++++++++++++++++++++ src/tests/mod.rs | 1 + 8 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/console.rs create mode 100644 src/tests/console.rs diff --git a/AGENTS.md b/AGENTS.md index 9c6f931..c17f060 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ so CI builds what was tested. `POST /refresh`, logout. - `src/manifest.rs`: parsing, matching, the live and embedded manifest. - `src/guard.rs`: `require_auth`, `authenticate`, the `User` extractor. +- `src/console.rs`: `console_router`, the admin dashboard proxy. - `src/cookies.rs`, `src/jwt.rs`, `src/jwks.rs`, `src/service_token.rs`: HS256 cookies and service tokens, RS256 against the API's JWKS. - `src/tests/`: unit tests against a fake auth API. diff --git a/README.md b/README.md index 25e055e..c84aef3 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,24 @@ asks the API for the message and hands it to you: A delivery error answers the request with 502 `delivery_failed`. +### The admin console + +`console_router()` serves the Seamless admin dashboard from your API, proxied from the auth API, so +it shares the origin and cookie scope of the `/auth` routes. It serves `/console`, the path the +dashboard is built against, so merge it (a router nested at `/console` never sees `/console/`): + +```rust +let app = Router::new() + .nest("/auth", auth.router()) + .merge(auth.console_router()); +``` + +It serves `GET` and `HEAD` only and forwards nothing but the method and path, so the browser's +cookies never reach the upstream. It refuses any path that could leave the console (dot segments, +encoded separators), and follows a redirect only while it stays inside the console on the auth API. +When you serve it, add your API's origin to the auth server's `ORIGINS` so passkey ceremonies +started in the console verify. + ## Features | Feature | Default | Purpose | diff --git a/src/adapter.rs b/src/adapter.rs index 85073fc..ab01f36 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -48,6 +48,7 @@ pub(crate) struct Inner { pub manifest: ManifestSource, pub tokens: ServiceTokens, pub refreshes: Arc, + pub console_client: std::sync::OnceLock>, } impl Adapter { @@ -71,6 +72,7 @@ impl Adapter { manifest, tokens, refreshes: Arc::default(), + console_client: std::sync::OnceLock::new(), }), } } diff --git a/src/console.rs b/src/console.rs new file mode 100644 index 0000000..0db9a98 --- /dev/null +++ b/src/console.rs @@ -0,0 +1,182 @@ +use std::time::Duration; + +use axum::Router; +use axum::body::Body; +use axum::extract::Request; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LAST_MODIFIED}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::Response; +use axum::routing::any; +use reqwest::Url; +use reqwest::redirect::Policy; +use serde_json::json; + +use crate::adapter::{Adapter, Inner, json_response}; +use crate::manifest::percent_decode; + +const CONSOLE_BASE_PATH: &str = "/console"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_REDIRECTS: usize = 5; + +/// The auth API's origin and the console's path on it. +#[derive(Clone)] +struct ConsoleOrigin { + base: Url, + prefix: String, +} + +impl ConsoleOrigin { + fn new(auth_server_url: &str) -> Option { + let base = Url::parse(auth_server_url).ok()?; + base.host_str()?; + let prefix = format!("{}{CONSOLE_BASE_PATH}", base.path().trim_end_matches('/')); + Some(ConsoleOrigin { base, prefix }) + } + + /// Holds a URL to the auth API's origin and the console subtree. + fn contains(&self, url: &Url) -> bool { + url.scheme() == self.base.scheme() + && url.host_str() == self.base.host_str() + && url.port_or_known_default() == self.base.port_or_known_default() + && (url.path() == self.prefix || url.path().starts_with(&format!("{}/", self.prefix))) + } + + /// Maps a path below the mount to the console on the auth API, refusing + /// anything that could leave the console subtree: an encoded separator (an + /// upstream that decodes it would read a traversal), and any dot segment, + /// literal or encoded. + fn upstream(&self, subpath: &str, query: Option<&str>) -> Option { + let lower = subpath.to_ascii_lowercase(); + if lower.contains("%2f") || lower.contains("%5c") || subpath.contains('\\') { + return None; + } + for segment in subpath.split('/') { + match percent_decode(segment) { + Some(decoded) if decoded != "." && decoded != ".." => {} + _ => return None, + } + } + + let suffix = match subpath { + "" | "/" => String::new(), + s if s.starts_with('/') => s.to_string(), + s => format!("/{s}"), + }; + let mut url = self.base.clone(); + url.set_path(&format!("{}{suffix}", self.prefix)); + url.set_query(query.filter(|q| !q.is_empty())); + self.contains(&url).then_some(url) + } +} + +impl Adapter { + /// Reverse-proxies the Seamless admin dashboard from the auth API, so it loads + /// from the same origin as the cookie-based auth routes. It serves `/console`, + /// the path the dashboard is built against, so merge it rather than nest it: + /// `Router::new().merge(auth.console_router())`. (A router nested at + /// `/console` never sees `/console/`, where the browser lands.) + /// + /// Nothing from the incoming request is forwarded but the method and the + /// path: the console is public static hosting, and the browser's session + /// cookies have no business at the upstream. + pub fn console_router(&self) -> Router + where + S: Clone + Send + Sync + 'static, + { + let inner = self.inner.clone(); + let handler = move |req: Request| { + let inner = inner.clone(); + async move { inner.serve_console(req).await } + }; + Router::new() + .route(CONSOLE_BASE_PATH, any(handler.clone())) + .route(&format!("{CONSOLE_BASE_PATH}/"), any(handler.clone())) + .route(&format!("{CONSOLE_BASE_PATH}/{{*path}}"), any(handler)) + } +} + +impl Inner { + /// Its own client: the adapter's follows no redirects at all, and the + /// console follows them while they stay inside it. + fn console_client(&self, origin: &ConsoleOrigin) -> Option { + self.console_client + .get_or_init(|| { + let origin = origin.clone(); + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(Policy::custom(move |attempt| { + if attempt.previous().len() >= MAX_REDIRECTS { + attempt.error("too many redirects") + } else if origin.contains(attempt.url()) { + attempt.follow() + } else { + attempt.error("redirect leaves the console") + } + })) + .build() + .ok() + }) + .clone() + } + + async fn serve_console(&self, req: Request) -> Response { + let method = req.method().clone(); + if method != Method::GET && method != Method::HEAD { + return json_response( + StatusCode::METHOD_NOT_ALLOWED, + &json!({ "error": "Method not allowed" }), + ); + } + + let invalid = || { + json_response( + StatusCode::BAD_REQUEST, + &json!({ "error": "Invalid console path" }), + ) + }; + let Some(origin) = ConsoleOrigin::new(&self.config.auth_server_url) else { + return invalid(); + }; + let subpath = req + .uri() + .path() + .strip_prefix(CONSOLE_BASE_PATH) + .unwrap_or_default(); + let Some(upstream) = origin.upstream(subpath, req.uri().query()) else { + return invalid(); + }; + let Some(client) = self.console_client(&origin) else { + return json_response( + StatusCode::BAD_GATEWAY, + &json!({ "error": "Console upstream unreachable" }), + ); + }; + + let res = match client.request(method.clone(), upstream).send().await { + Ok(res) => res, + Err(_) => { + return json_response( + StatusCode::BAD_GATEWAY, + &json!({ "error": "Console upstream unreachable" }), + ); + } + }; + + let mut headers = HeaderMap::new(); + for name in [CONTENT_TYPE, CACHE_CONTROL, ETAG, LAST_MODIFIED] { + if let Some(value) = res.headers().get(&name) { + headers.insert(name, value.clone()); + } + } + let status = res.status(); + let body = if method == Method::HEAD { + Body::empty() + } else { + Body::new(reqwest::Body::from(res)) + }; + let mut response = Response::new(body); + *response.status_mut() = status; + *response.headers_mut() = headers; + response + } +} diff --git a/src/lib.rs b/src/lib.rs index 026d058..2911290 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ mod adapter; mod client_ip; +mod console; mod cookies; mod delivery; mod guard; diff --git a/src/manifest.rs b/src/manifest.rs index b9576a5..9d52e33 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -207,7 +207,7 @@ fn segments(path: &str) -> Vec<&str> { /// Decodes a path segment. Refuses a malformed escape or a result that is not /// UTF-8, as Go's `url.PathUnescape` does. -fn percent_decode(segment: &str) -> Option { +pub(crate) fn percent_decode(segment: &str) -> Option { let bytes = segment.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; diff --git a/src/tests/console.rs b/src/tests/console.rs new file mode 100644 index 0000000..06ea566 --- /dev/null +++ b/src/tests/console.rs @@ -0,0 +1,170 @@ +use axum::body::Body; +use axum::http::HeaderValue; +use axum::response::Response; +use serde_json::json; + +use super::*; + +fn console_app(adapter: &Adapter) -> Router { + Router::new().merge(adapter.console_router()) +} + +fn html(body: &'static str) -> Handler { + Arc::new(move |_| { + let mut res = Response::new(Body::from(body)); + res.headers_mut() + .insert("content-type", HeaderValue::from_static("text/html")); + res + }) +} + +fn redirect(to: &'static str) -> Handler { + Arc::new(move |_| { + let mut res = Response::new(Body::empty()); + *res.status_mut() = axum::http::StatusCode::FOUND; + res.headers_mut() + .insert("location", HeaderValue::from_static(to)); + res + }) +} + +#[tokio::test] +async fn the_console_is_proxied_from_the_auth_api() { + let api = FakeApi::start().await; + api.on( + "GET", + "/console/assets/app.js", + Arc::new(|_| { + let mut res = Response::new(Body::from("console();")); + let headers = res.headers_mut(); + headers.insert("content-type", HeaderValue::from_static("text/javascript")); + headers.insert("cache-control", HeaderValue::from_static("max-age=60")); + headers.insert("set-cookie", HeaderValue::from_static("upstream=1")); + res + }), + ); + let adapter = api.adapter(|b| b); + let cookie = signed_cookie("seamless-access", json!({ "sub": "u1", "token": "t" })); + + let r = call("GET", "/console/assets/app.js?v=2") + .cookie(&cookie) + .header("authorization", "Bearer t") + .through(console_app(&adapter)) + .await; + + assert_eq!(r.status, 200); + assert_eq!(r.text, "console();"); + assert_eq!(r.headers["content-type"], "text/javascript"); + assert_eq!(r.headers["cache-control"], "max-age=60"); + assert!( + r.headers.get("set-cookie").is_none(), + "an upstream Set-Cookie reached the browser" + ); + let recorded = &api.calls_to("GET", "/console/assets/app.js")[0]; + assert_eq!(recorded.query.as_deref(), Some("v=2")); + assert_eq!(recorded.header("cookie"), ""); + assert_eq!(recorded.header("authorization"), ""); +} + +#[tokio::test] +async fn the_console_root_and_head() { + let api = FakeApi::start().await; + api.on("GET", "/console", html("")); + api.on("HEAD", "/console", html("")); + let adapter = api.adapter(|b| b); + + let r = call("GET", "/console/") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "")); + let r = call("HEAD", "/console/") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "")); +} + +#[tokio::test] +async fn the_console_refuses_other_methods() { + let api = FakeApi::start().await; + let adapter = api.adapter(|b| b); + for method in ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] { + let r = call(method, "/console/x") + .through(console_app(&adapter)) + .await; + assert_eq!(r.status, 405, "{method}"); + } +} + +#[tokio::test] +async fn the_console_never_leaves_its_subtree() { + let api = FakeApi::start().await; + let adapter = api.adapter(|b| b); + for target in [ + "/console/../admin/users", + "/console/%2e%2e/admin/users", + "/console/assets/%2E%2E/%2e%2e/jwks.json", + "/console/..%2fadmin", + "/console/..%2Fadmin", + "/console/..%5cadmin", + "/console/a/./b", + "/console/%zz", + ] { + let r = call("GET", target).through(console_app(&adapter)).await; + assert_eq!(r.status, 400, "{target}"); + } + assert!(api.calls_to("GET", "/admin/users").is_empty()); + assert!(api.calls_to("GET", "/.well-known/jwks.json").is_empty()); +} + +#[tokio::test] +async fn the_console_follows_redirects_only_within_itself() { + let api = FakeApi::start().await; + api.on("GET", "/console/old", redirect("/console/new")); + api.on("GET", "/console/new", html("moved")); + api.on("GET", "/console/escape", redirect("/.well-known/jwks.json")); + let adapter = api.adapter(|b| b); + + let r = call("GET", "/console/old") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "moved")); + let r = call("GET", "/console/escape") + .through(console_app(&adapter)) + .await; + assert_eq!(r.status, 502); + assert!(api.calls_to("GET", "/.well-known/jwks.json").is_empty()); +} + +#[tokio::test] +async fn the_console_under_an_auth_server_path() { + let api = FakeApi::start().await; + api.on("GET", "/base/console/x", html("ok")); + let adapter = Adapter::builder(format!("{}/base", api.url)) + .cookie_secret(TEST_SECRET) + .service_secret(TEST_SERVICE) + .jwks_kid("test-main") + .disable_manifest_fetch(true) + .build() + .unwrap(); + + let r = call("GET", "/console/x") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "ok")); +} + +#[tokio::test] +async fn the_console_reports_an_unreachable_upstream() { + let adapter = Adapter::builder("http://127.0.0.1:9") + .cookie_secret(TEST_SECRET) + .service_secret(TEST_SERVICE) + .jwks_kid("test-main") + .disable_manifest_fetch(true) + .build() + .unwrap(); + let r = call("GET", "/console/x") + .through(console_app(&adapter)) + .await; + assert_eq!(r.status, 502); + assert!(r.text.contains("unreachable")); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index c7621b5..523d7c6 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -2,6 +2,7 @@ mod adapter; mod client_ip; +mod console; mod guard; mod manifest;