Files
ai-app/server/src/auth.rs
T
irisandClaude Fable 5.1 1656b058bf ai-server --enroll-link: mint one more device's link while the server runs
Prints the enrollment URI, one line on stdout, and exits; the running
server adopts the token the first time that device presents it, via the
spool wg-app-link's enroll module now provides (submodule bumped to
d35c880). This is the server half of enrolling through Dev Updater: its
coming per-component Enroll button runs this command and opens whatever
it prints on the phone, which is what a reinstall -- a signing change, a
new phone -- needs when nobody is at the terminal the QR is printed on.

Verified against the sandbox server: minted while it ran, first request
with the token served and the token moved into config.ron, spool empty,
second request served as an ordinary token. 108 tests, clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:44:17 -04:00

247 lines
9.3 KiB
Rust

//! Bearer-token auth for the entire HTTP surface.
//!
//! This server's API *is* remote code execution, so the token gates every
//! route with zero unauthenticated endpoints -- the middleware is applied
//! once around the whole router (including the fallback) in `main.rs`,
//! never per-route, so a new route can't forget it. See PLAN.md's security
//! section for the threat model; the short version is that the token gates
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
//! defense in depth rather than the sole gate.
//!
//! Nothing in this module -- and nothing anywhere else -- may log the
//! Authorization header or the token; the test below holds a tripwire
//! against a logging change silently starting to. It is one test covering
//! both gating and logging on purpose -- see the note in it.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use wg_app_link::enroll::{take_pending, token_hash_hex, token_matches};
use crate::config::TokenEntry;
use crate::session::SessionManager;
/// Applied to every rejection. Not against brute force -- infeasible at 256
/// bits -- but so a scanner probing the port shows up as a slow, loggable
/// drip rather than a fast one.
const REJECT_DELAY: Duration = Duration::from_millis(300);
pub async fn require_token(
State(manager): State<Arc<SessionManager>>,
request: Request,
next: Next,
) -> Response {
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
if let Some(token) = presented {
let hashes: Vec<String> = manager
.tokens()
.into_iter()
.map(|entry| entry.sha256)
.collect();
if token_matches(token, &hashes) {
return next.run(request).await;
}
// A link minted by `--enroll-link` while this server was running:
// the entry moves from the spool into the config here, on first
// use, and is an ordinary token from then on.
match take_pending(&manager.pending_enrollments_dir(), token) {
Ok(Some(name)) => {
let entry = TokenEntry {
name,
sha256: token_hash_hex(token),
};
match manager.add_token(entry) {
Ok(()) => {
tracing::info!("adopted an enrollment minted with --enroll-link");
return next.run(request).await;
}
Err(error) => tracing::error!("couldn't adopt the enrollment: {error:#}"),
}
}
Ok(None) => {}
Err(error) => tracing::warn!("couldn't read the enrollment spool: {error:#}"),
}
}
// Peer address only -- never the header value. Absent when there is no
// real socket (tests driving the router directly).
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.to_string())
.unwrap_or_else(|| "unknown peer".to_string());
tracing::warn!("rejected request from {peer}: missing or invalid bearer token");
tokio::time::sleep(REJECT_DELAY).await;
(StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use axum::Router;
use axum::body::Body;
use axum::routing::get;
use tower::ServiceExt;
use wg_app_link::enroll::{generate_token, spool_pending};
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
let manager = Arc::new(
SessionManager::new(
dir.join("config.ron"),
dir.join("sessions"),
dir.join("models"),
)
.expect("manager"),
);
manager
.set_tokens(vec![TokenEntry {
name: "phone".to_string(),
sha256: token_hash_hex(token),
}])
.expect("set token");
manager
}
fn guarded_router(manager: Arc<SessionManager>) -> Router {
Router::new()
.route("/probe", get(|| async { "ok" }))
.fallback(|| async { StatusCode::NOT_FOUND })
.layer(axum::middleware::from_fn_with_state(manager, require_token))
}
fn request(path: &str, auth: Option<&str>) -> Request {
let mut builder = axum::http::Request::builder().uri(path);
if let Some(auth) = auth {
builder = builder.header(header::AUTHORIZATION, auth);
}
builder.body(Body::empty()).expect("request")
}
/// One test rather than separate gating and logging tests,
/// deliberately: tracing caches callsite interest process-wide, so a
/// test that hits the rejection path with no subscriber installed can
/// poison the interest cache for the one that captures logs. Keeping
/// every exercise of the middleware under the capturing subscriber
/// makes the log assertions deterministic.
#[tokio::test]
async fn gates_every_route_and_never_logs_the_token() {
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Capture {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
type Writer = Capture;
fn make_writer(&'a self) -> Capture {
self.clone()
}
}
let capture = Capture::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(capture.clone())
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let dir = tempfile::tempdir().expect("tempdir");
let token = generate_token();
let router = guarded_router(manager_with_token(dir.path(), &token));
// No header, wrong token, wrong scheme: 401 everywhere, including
// paths that don't exist -- a scanner learns nothing.
for (path, auth) in [
("/probe", None),
("/probe", Some("Bearer wrong".to_string())),
("/probe", Some(format!("Basic {token}"))),
("/no-such-route", None),
] {
let response = router
.clone()
.oneshot(request(path, auth.as_deref()))
.await
.expect("response");
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{path} {auth:?}"
);
}
let ok = router
.clone()
.oneshot(request("/probe", Some(&format!("Bearer {token}"))))
.await
.expect("response");
assert_eq!(ok.status(), StatusCode::OK);
// The tripwire that keeps a future logging change (e.g. logging
// request headers) from silently leaking credentials.
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
assert!(
!logged.contains(&token),
"the bearer token leaked into the logs: {logged}"
);
// The rejections themselves do get logged (that's the point).
assert!(logged.contains("missing or invalid bearer token"));
}
/// A token spooled by `--enroll-link` is refused by nothing: the first
/// request carrying it is served, and from then on it is in the config
/// like any other.
#[tokio::test]
async fn a_spooled_enrollment_is_adopted_on_first_use() {
let dir = tempfile::tempdir().expect("tempdir");
let manager = manager_with_token(dir.path(), "first");
let spooled = generate_token();
spool_pending(&manager.pending_enrollments_dir(), "tablet", &spooled).unwrap();
let router = guarded_router(Arc::clone(&manager));
let bearer = format!("Bearer {spooled}");
let served = router
.clone()
.oneshot(request("/probe", Some(&bearer)))
.await
.unwrap();
assert_eq!(served.status(), StatusCode::OK);
let names: Vec<String> = manager.tokens().into_iter().map(|t| t.name).collect();
assert_eq!(names, vec!["phone".to_string(), "tablet".to_string()]);
assert!(
std::fs::read_dir(manager.pending_enrollments_dir())
.unwrap()
.next()
.is_none(),
"the spool entry is consumed"
);
let again = router
.oneshot(request("/probe", Some(&bearer)))
.await
.unwrap();
assert_eq!(again.status(), StatusCode::OK, "now an ordinary token");
let stranger = guarded_router(manager)
.oneshot(request("/probe", Some("Bearer nope")))
.await
.unwrap();
assert_eq!(stranger.status(), StatusCode::UNAUTHORIZED);
}
}