Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub(crate) struct Inner {
pub manifest: ManifestSource,
pub tokens: ServiceTokens,
pub refreshes: Arc<Refresher>,
pub console_client: std::sync::OnceLock<Option<reqwest::Client>>,
}

impl Adapter {
Expand All @@ -71,6 +72,7 @@ impl Adapter {
manifest,
tokens,
refreshes: Arc::default(),
console_client: std::sync::OnceLock::new(),
}),
}
}
Expand Down
182 changes: 182 additions & 0 deletions src/console.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<Url> {
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<S>(&self) -> Router<S>
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<reqwest::Client> {
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
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

mod adapter;
mod client_ip;
mod console;
mod cookies;
mod delivery;
mod guard;
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
pub(crate) fn percent_decode(segment: &str) -> Option<String> {
let bytes = segment.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
Expand Down
Loading
Loading