use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::{Duration, Instant};
use axum::extract::State;
use axum::http::{HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use sha2::{Digest, Sha256};
use super::state::AppState;
pub(super) fn authorize(
path: &str,
method: &Method,
header_token: Option<&str>,
query_token: Option<&str>,
api_token: &str,
sessions: &SessionStore,
now: Instant,
) -> Result<(), StatusCode> {
if method == Method::OPTIONS {
return Ok(());
}
if is_auth_exempt_path(path) {
return Ok(());
}
if let Some(token) = header_token {
if ct_eq(token, api_token) || sessions.is_valid(token, now) {
return Ok(());
}
}
if accepts_query_token(path) {
if let Some(token) = query_token {
if sessions.is_valid(token, now) {
return Ok(());
}
}
}
Err(StatusCode::UNAUTHORIZED)
}
fn extract_bearer(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|t| t.to_string())
}
fn extract_query_token(query: Option<&str>) -> Option<String> {
let query = query?;
for pair in query.split('&') {
if let Some(token) = pair.strip_prefix("token=") {
return Some(token.to_string());
}
}
None
}
pub(super) async fn auth_middleware(
State(state): State<Arc<AppState>>,
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let header_token = extract_bearer(req.headers());
let query_token = extract_query_token(req.uri().query());
let now = Instant::now();
match authorize(
&path,
&method,
header_token.as_deref(),
query_token.as_deref(),
&state.api_token,
&state.sessions,
now,
) {
Ok(()) => next.run(req).await,
Err(status) => (
status,
Json(serde_json::json!({
"error": "missing or invalid Authorization: Bearer token"
})),
)
.into_response(),
}
}
pub(super) async fn create_session(
State(state): State<Arc<AppState>>,
req: axum::http::Request<axum::body::Body>,
) -> Response {
let is_durable = extract_bearer(req.headers()).is_some_and(|t| ct_eq(&t, &state.api_token));
if !is_durable {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "durable API token required to mint a session"
})),
)
.into_response();
}
let token = state.sessions.issue(Instant::now());
(
StatusCode::OK,
Json(serde_json::json!({
"session_token": token,
"expires_in": SESSION_TOKEN_TTL_SECS,
})),
)
.into_response()
}
fn is_auth_exempt_path(path: &str) -> bool {
path == "/health" || path.starts_with("/constitution")
}
fn accepts_query_token(path: &str) -> bool {
matches!(
path,
"/gui"
| "/gui/"
| "/ws"
| "/ws/direct"
| "/events"
| "/direct/events"
| "/peers/events"
| "/presence/events"
)
}
pub(super) fn is_allowed_loopback_origin(origin: &HeaderValue) -> bool {
origin
.to_str()
.ok()
.is_some_and(is_allowed_loopback_origin_str)
}
fn is_allowed_loopback_origin_str(origin: &str) -> bool {
let Some(authority) = origin
.strip_prefix("http://")
.or_else(|| origin.strip_prefix("https://"))
else {
return false;
};
if authority.is_empty() || authority.contains('/') || authority.contains('@') {
return false;
}
let Some(host) = origin_host_without_port(authority) else {
return false;
};
matches!(host, "127.0.0.1" | "::1")
}
fn origin_host_without_port(authority: &str) -> Option<&str> {
if let Some(rest) = authority.strip_prefix('[') {
let (host, port) = rest.split_once(']')?;
if port.is_empty() || valid_origin_port(port.strip_prefix(':')?) {
Some(host)
} else {
None
}
} else {
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) if !host.contains(':') => (host, Some(port)),
Some(_) => return None,
None => (authority, None),
};
if port.is_none_or(valid_origin_port) {
Some(host)
} else {
None
}
}
}
fn valid_origin_port(port: &str) -> bool {
!port.is_empty() && port.parse::<u16>().is_ok()
}
fn ct_eq(a: &str, b: &str) -> bool {
let ha = Sha256::digest(a.as_bytes());
let hb = Sha256::digest(b.as_bytes());
use subtle::ConstantTimeEq;
ha.ct_eq(&hb).into()
}
pub(super) const SESSION_TOKEN_TTL: Duration = Duration::from_secs(10 * 60);
const SESSION_TOKEN_TTL_SECS: u64 = 10 * 60;
struct AuthSession {
token_hash: [u8; 32],
expires_at: Instant,
}
pub(super) struct SessionStore {
sessions: StdMutex<Vec<AuthSession>>,
ttl: Duration,
}
impl SessionStore {
pub(super) fn new(ttl: Duration) -> Self {
Self {
sessions: StdMutex::new(Vec::new()),
ttl,
}
}
fn issue(&self, now: Instant) -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let token = hex::encode(bytes);
let session = AuthSession {
token_hash: Sha256::digest(token.as_bytes()).into(),
expires_at: now + self.ttl,
};
if let Ok(mut guard) = self.sessions.lock() {
guard.push(session);
guard.retain(|s| s.expires_at > now);
}
token
}
fn is_valid(&self, token: &str, now: Instant) -> bool {
use subtle::ConstantTimeEq;
let candidate = Sha256::digest(token.as_bytes());
let Ok(mut guard) = self.sessions.lock() else {
return false;
};
guard.retain(|s| s.expires_at > now);
guard.iter().any(|s| candidate.ct_eq(&s.token_hash).into())
}
}
pub(super) async fn load_or_generate_api_token(
data_dir: &std::path::Path,
) -> anyhow::Result<String> {
let token_path = data_dir.join("api-token");
if token_path.exists() {
let token = tokio::fs::read_to_string(&token_path)
.await
.context("failed to read api-token")?
.trim()
.to_string();
if token.len() >= 32 {
tracing::info!("API token loaded from {}", token_path.display());
return Ok(token);
}
}
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let token = hex::encode(bytes);
tokio::fs::write(&token_path, &token)
.await
.context("failed to write api-token")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&token_path, perms)
.await
.context("failed to set api-token permissions")?;
}
tracing::info!("API token generated at {}", token_path.display());
Ok(token)
}
use anyhow::Context as _;
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, Method, StatusCode};
const TEST_API_TOKEN: &str = "x0x-test-auth-matrix-token";
fn matrix_method(path: &str) -> Method {
if path == "/agent/sign" || path == "/agent/verify" {
Method::POST
} else {
Method::GET
}
}
fn authorize_protected(
path: &str,
header_token: Option<&str>,
query_token: Option<&str>,
sessions: &SessionStore,
) -> Result<(), StatusCode> {
authorize(
path,
&matrix_method(path),
header_token,
query_token,
TEST_API_TOKEN,
sessions,
Instant::now(),
)
}
#[test]
fn auth_matrix_protected_endpoints_reject_without_token() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
for path in ["/status", "/agent", "/agent/sign", "/agent/verify"] {
assert_eq!(
authorize_protected(path, None, None, &sessions),
Err(StatusCode::UNAUTHORIZED),
"{path} without a token must be 401"
);
}
}
#[test]
fn auth_matrix_protected_endpoints_reject_wrong_token() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
for path in ["/status", "/agent", "/agent/sign", "/agent/verify"] {
assert_eq!(
authorize_protected(path, Some("not-the-real-token"), None, &sessions),
Err(StatusCode::UNAUTHORIZED),
"{path} with a wrong bearer must be 401"
);
}
}
#[test]
fn auth_matrix_protected_endpoints_accept_correct_token() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
for path in ["/status", "/agent", "/agent/sign", "/agent/verify"] {
assert!(
authorize_protected(path, Some(TEST_API_TOKEN), None, &sessions).is_ok(),
"{path} with the correct bearer must pass (Ok)"
);
}
}
#[test]
fn auth_matrix_protected_endpoints_accept_session_bearer() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
let now = Instant::now();
let session = sessions.issue(now);
for path in ["/status", "/agent", "/agent/sign", "/agent/verify"] {
assert!(
authorize_protected(path, Some(&session), None, &sessions).is_ok(),
"{path} with a session bearer must pass (Ok)"
);
}
}
#[test]
fn auth_matrix_exempt_paths_serve_without_token() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
for path in ["/health", "/constitution", "/constitution/json"] {
assert!(
authorize(
path,
&Method::GET,
None,
None,
TEST_API_TOKEN,
&sessions,
Instant::now()
)
.is_ok(),
"{path} is auth-exempt and must pass without a token"
);
}
}
#[test]
fn auth_matrix_browser_endpoints_accept_query_token() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
let now = Instant::now();
let session = sessions.issue(now);
for path in [
"/gui",
"/gui/",
"/ws",
"/ws/direct",
"/events",
"/direct/events",
"/peers/events",
"/presence/events",
] {
assert!(
authorize(
path,
&Method::GET,
None,
Some(&session),
TEST_API_TOKEN,
&sessions,
now
)
.is_ok(),
"{path}?token=<session> is a browser endpoint and must accept the session token"
);
}
}
#[test]
fn auth_matrix_browser_endpoints_reject_durable_query_token() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
let now = Instant::now();
for path in ["/gui", "/ws", "/ws/direct", "/events", "/peers/events"] {
assert_eq!(
authorize(
path,
&Method::GET,
None,
Some(TEST_API_TOKEN),
TEST_API_TOKEN,
&sessions,
now
),
Err(StatusCode::UNAUTHORIZED),
"{path}?token=<durable> must be 401 — durable tokens are never valid in a query string"
);
}
}
#[test]
fn auth_matrix_query_token_rejected_on_protected_paths() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
let now = Instant::now();
for path in ["/status", "/agent/sign", "/agent/verify"] {
assert_eq!(
authorize(
path,
&matrix_method(path),
None,
Some(TEST_API_TOKEN),
TEST_API_TOKEN,
&sessions,
now
),
Err(StatusCode::UNAUTHORIZED),
"{path}?token= must reject (not a browser endpoint)"
);
}
}
#[test]
fn auth_matrix_options_preflight_bypasses_auth() {
let sessions = SessionStore::new(SESSION_TOKEN_TTL);
assert!(
authorize(
"/status",
&Method::OPTIONS,
None,
None,
TEST_API_TOKEN,
&sessions,
Instant::now()
)
.is_ok(),
"OPTIONS preflight must bypass bearer auth"
);
}
#[test]
fn cors_origin_allows_only_literal_loopback_ips() {
for origin in [
"http://127.0.0.1",
"http://127.0.0.1:12700",
"http://[::1]",
"http://[::1]:12700",
] {
assert!(
is_allowed_loopback_origin_str(origin),
"expected literal loopback IP origin to be allowed: {origin}"
);
}
for origin in [
"http://localhost",
"http://localhost:12700",
"https://localhost",
"http://localhost.evil.example",
"http://127.0.0.1.evil.example",
"http://[::1].evil.example",
"http://evil.localhost",
"http://localhost@evil.example",
"http://localhost:bad",
"ftp://localhost",
"null",
] {
assert!(
!is_allowed_loopback_origin_str(origin),
"expected non-IP-loopback origin to be rejected: {origin}"
);
}
}
#[test]
fn cors_origin_allows_loopback_header_values() {
for origin in [
"http://127.0.0.1",
"http://127.0.0.1:12700",
"http://[::1]",
"http://[::1]:12700",
] {
let hv = HeaderValue::from_str(origin).expect("valid header value");
assert!(
is_allowed_loopback_origin(&hv),
"CORS predicate must allow literal loopback origin: {origin}"
);
}
}
#[test]
fn cors_origin_rejects_localhost_header_value() {
let hv = HeaderValue::from_str("http://localhost:12700").expect("valid header value");
assert!(
!is_allowed_loopback_origin(&hv),
"CORS predicate must reject localhost origin"
);
}
#[test]
fn gui_requires_auth_but_accepts_query_token_bootstrap() {
assert!(!is_auth_exempt_path("/gui"));
assert!(!is_auth_exempt_path("/gui/"));
assert!(accepts_query_token("/gui"));
assert!(accepts_query_token("/gui/"));
assert!(accepts_query_token("/peers/events"));
assert!(accepts_query_token("/presence/events"));
}
#[test]
fn session_store_issues_and_validates_a_token() {
let store = SessionStore::new(SESSION_TOKEN_TTL);
let now = Instant::now();
let token = store.issue(now);
assert!(token.len() >= 32, "session token must be opaque hex");
assert!(
store.is_valid(&token, now),
"a freshly-issued token must validate"
);
assert!(
!store.is_valid("not-a-real-token", now),
"a wrong token must not validate",
);
}
#[test]
fn session_store_token_expires_after_ttl() {
let store = SessionStore::new(SESSION_TOKEN_TTL);
let t0 = Instant::now();
let token = store.issue(t0);
assert!(store.is_valid(&token, t0));
let just_before = t0 + SESSION_TOKEN_TTL - Duration::from_nanos(1);
assert!(
store.is_valid(&token, just_before),
"token must be valid right up to the TTL boundary",
);
let just_after = t0 + SESSION_TOKEN_TTL + Duration::from_nanos(1);
assert!(
!store.is_valid(&token, just_after),
"token must be invalid past the TTL",
);
}
#[test]
fn session_store_prunes_expired_entries_on_validate() {
let store = SessionStore::new(SESSION_TOKEN_TTL);
let t0 = Instant::now();
let dead = store.issue(t0); let alive = store.issue(t0); let far_future = t0 + SESSION_TOKEN_TTL * 10;
assert!(!store.is_valid(&dead, far_future));
assert!(!store.is_valid(&alive, far_future));
let fresh = store.issue(far_future);
assert!(store.is_valid(&fresh, far_future));
}
#[test]
fn ct_eq_is_constant_time_and_correct() {
assert!(ct_eq("abc", "abc"));
assert!(!ct_eq("abc", "abd"));
assert!(!ct_eq("abc", "ab"));
assert!(!ct_eq("", "abc"));
assert!(ct_eq("", ""));
}
#[test]
fn extract_bearer_strips_prefix() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer my-secret"),
);
assert_eq!(extract_bearer(&headers).as_deref(), Some("my-secret"));
}
#[test]
fn extract_bearer_returns_none_without_header() {
let headers = HeaderMap::new();
assert!(extract_bearer(&headers).is_none());
}
#[test]
fn extract_query_token_finds_first() {
assert_eq!(
extract_query_token(Some("foo=bar&token=abc&baz=qux")).as_deref(),
Some("abc")
);
assert_eq!(
extract_query_token(Some("token=first&token=second")).as_deref(),
Some("first")
);
assert!(extract_query_token(Some("no_token_here")).is_none());
assert!(extract_query_token(None).is_none());
}
}