use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use subtle::ConstantTimeEq;
use tensor_wasm_core::types::TenantId;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::classify::{ServerErrorsAsFailures, SharedClassifier};
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
use crate::token_scope::{parse_tokens_env, TokenScope};
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_CONCURRENCY_LIMIT: usize = 64;
pub const PROBE_CONCURRENCY_LIMIT: usize = 256;
pub const INVOKE_CONCURRENCY_LIMIT: usize = 32;
pub const READ_CONCURRENCY_LIMIT: usize = 64;
pub const WRITE_CONCURRENCY_LIMIT: usize = 16;
pub const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
pub const ENV_API_TOKENS: &str = "TENSOR_WASM_API_TOKENS";
pub const ENV_ALLOW_DEV_MODE: &str = "TENSOR_WASM_API_ALLOW_DEV_MODE";
pub const ENV_KERNEL_PUBLISH_TOKENS: &str = "TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS";
pub const MAX_AUTH_HEADER_BYTES: usize = 1024;
pub const ENV_REQUIRE_TENANT: &str = "TENSOR_WASM_API_REQUIRE_TENANT";
pub const HEADER_TENANT: &str = "X-TensorWasm-Tenant";
pub const ENV_TRUSTED_HOSTS: &str = "TENSOR_WASM_API_TRUSTED_HOSTS";
#[derive(Debug, Clone, Default)]
pub struct TrustedHosts(Arc<Vec<String>>);
impl TrustedHosts {
pub fn from_env() -> Self {
let raw = std::env::var(ENV_TRUSTED_HOSTS).unwrap_or_default();
Self::from_raw(&raw)
}
pub fn from_raw(raw: &str) -> Self {
let parsed: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
Self(Arc::new(parsed))
}
pub fn allow_all() -> Self {
Self(Arc::new(Vec::new()))
}
pub fn from_hosts<I, S>(iter: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let parsed: Vec<String> = iter
.into_iter()
.map(|s| s.into().trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
Self(Arc::new(parsed))
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn contains(&self, host: &str) -> bool {
if self.0.is_empty() {
return true;
}
let host_lc = host.trim().to_ascii_lowercase();
if self.0.contains(&host_lc) {
return true;
}
let allow_has_port = self.0.iter().any(|a| a.contains(':'));
if allow_has_port {
return false;
}
if let Some(stripped) = strip_default_port(&host_lc) {
return self.0.iter().any(|allowed| allowed == stripped);
}
false
}
}
fn strip_default_port(host_lc: &str) -> Option<&str> {
for suffix in [":443", ":80"] {
if let Some(stripped) = host_lc.strip_suffix(suffix) {
return Some(stripped);
}
}
None
}
fn env_trusted_hosts_fallback() -> TrustedHosts {
static ONCE: std::sync::OnceLock<TrustedHosts> = std::sync::OnceLock::new();
ONCE.get_or_init(TrustedHosts::from_env).clone()
}
#[derive(Debug, Clone, Default)]
pub struct KernelPublishTokens {
token_ids: Arc<std::collections::HashSet<crate::rate_limit::TokenId>>,
}
impl KernelPublishTokens {
pub fn from_env() -> Self {
let raw = std::env::var(ENV_KERNEL_PUBLISH_TOKENS).unwrap_or_default();
Self::from_raw(&raw)
}
pub fn from_raw(raw: &str) -> Self {
let token_ids = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(crate::rate_limit::TokenId::from_bearer)
.collect();
Self {
token_ids: Arc::new(token_ids),
}
}
pub fn from_tokens<I, S>(iter: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let token_ids = iter
.into_iter()
.map(|s| crate::rate_limit::TokenId::from_bearer(s.as_ref()))
.collect();
Self {
token_ids: Arc::new(token_ids),
}
}
pub fn is_empty(&self) -> bool {
self.token_ids.is_empty()
}
pub fn allows(&self, token_id: crate::rate_limit::TokenId) -> bool {
self.token_ids.contains(&token_id)
}
}
pub async fn host_validate(req: Request, next: Next) -> Response {
let allow = req
.extensions()
.get::<TrustedHosts>()
.cloned()
.unwrap_or_else(env_trusted_hosts_fallback);
if allow.is_empty() {
return next.run(req).await;
}
let host_header = req
.headers()
.get(axum::http::header::HOST)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_owned());
let authority = req.uri().authority().map(|a| a.as_str().to_owned());
let host = host_header.or(authority);
match host {
Some(h) if allow.contains(&h) => next.run(req).await,
_ => envelope(
StatusCode::BAD_REQUEST,
"bad_request",
"Host header missing or not in TENSOR_WASM_API_TRUSTED_HOSTS allowlist",
),
}
}
pub fn timeout_layer(d: Duration) -> TimeoutLayer {
TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, d)
}
pub fn trace_layer() -> TraceLayer<SharedClassifier<ServerErrorsAsFailures>> {
TraceLayer::new_for_http()
}
pub fn concurrency_limit_layer(max: usize) -> ConcurrencyLimitLayer {
ConcurrencyLimitLayer::new(max)
}
pub fn body_limit_layer(max_bytes: usize) -> axum::extract::DefaultBodyLimit {
axum::extract::DefaultBodyLimit::max(max_bytes)
}
pub const ENV_CORS_ALLOWED_ORIGINS: &str = "TENSOR_WASM_API_CORS_ALLOWED_ORIGINS";
const CORS_ALLOWED_HEADERS: &[&str] = &[
"authorization",
"content-type",
"x-tensorwasm-tenant",
"traceparent",
];
#[derive(Debug, Clone, Default)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
}
impl CorsConfig {
pub fn from_env() -> Self {
let raw = std::env::var(ENV_CORS_ALLOWED_ORIGINS).unwrap_or_default();
let allowed_origins: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
Self { allowed_origins }
}
pub fn from_origins<I, S>(iter: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed_origins: iter.into_iter().map(Into::into).collect(),
}
}
}
pub fn cors_layer(cfg: &CorsConfig) -> CorsLayer {
use axum::http::Method;
let allowed_methods = [Method::GET, Method::POST, Method::DELETE];
let base = CorsLayer::new()
.allow_methods(allowed_methods)
.allow_headers(
CORS_ALLOWED_HEADERS
.iter()
.filter_map(|h| h.parse::<axum::http::HeaderName>().ok())
.collect::<Vec<_>>(),
);
if cfg.allowed_origins.is_empty() {
base
} else {
let parsed: Vec<axum::http::HeaderValue> = cfg
.allowed_origins
.iter()
.filter_map(|origin| origin.parse::<axum::http::HeaderValue>().ok())
.collect();
base.allow_origin(AllowOrigin::list(parsed))
}
}
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub scopes: Arc<HashMap<String, TokenScope>>,
pub deprecated_count: usize,
pub dev_mode_allowed: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
scopes: Arc::new(HashMap::new()),
deprecated_count: 0,
dev_mode_allowed: true,
}
}
}
impl AuthConfig {
pub fn from_env() -> Self {
let raw = std::env::var(ENV_API_TOKENS).unwrap_or_default();
let parsed = parse_tokens_env(&raw);
let dev_mode_allowed = std::env::var(ENV_ALLOW_DEV_MODE).is_ok_and(|v| {
let v = v.trim();
v == "1" || v.eq_ignore_ascii_case("true")
});
if parsed.token_scopes.is_empty() {
tracing::warn!(
target: "tensor_wasm_api::middleware",
env = ENV_API_TOKENS,
"TENSOR_WASM_API_TOKENS empty; API accepts all requests (dev mode)",
);
if !dev_mode_allowed {
tracing::warn!(
target: "tensor_wasm_api::middleware",
env = ENV_ALLOW_DEV_MODE,
"{} not set; refusing dev-mode pass-through โ every request \
will be rejected with 401. Configure {} to enable bearer \
auth, or set {}=1 to explicitly acknowledge an open gateway",
ENV_ALLOW_DEV_MODE,
ENV_API_TOKENS,
ENV_ALLOW_DEV_MODE,
);
}
}
if parsed.deprecated_count > 0 {
tracing::warn!(
target: "tensor_wasm_api::middleware",
env = ENV_API_TOKENS,
count = parsed.deprecated_count,
"bare bearer tokens in {} are deprecated; switch to \
`token:tenant=...` (or `token:tenant=*` for the current \
wildcard behaviour) โ bare entries are scheduled for \
removal in v1.0",
ENV_API_TOKENS,
);
}
Self {
scopes: Arc::new(parsed.token_scopes),
deprecated_count: parsed.deprecated_count,
dev_mode_allowed,
}
}
pub fn from_tokens<I, S>(iter: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut scopes: HashMap<String, TokenScope> = HashMap::new();
for s in iter {
scopes.insert(s.into(), TokenScope::all());
}
Self {
scopes: Arc::new(scopes),
deprecated_count: 0,
dev_mode_allowed: true,
}
}
pub fn from_scopes<I, S>(iter: I) -> Self
where
I: IntoIterator<Item = (S, TokenScope)>,
S: Into<String>,
{
let mut scopes: HashMap<String, TokenScope> = HashMap::new();
for (k, v) in iter {
scopes.insert(k.into(), v);
}
Self {
scopes: Arc::new(scopes),
deprecated_count: 0,
dev_mode_allowed: true,
}
}
pub fn accepts(&self, token: &str) -> bool {
self.scope_for(token).is_some()
}
pub fn scope_for(&self, token: &str) -> Option<&TokenScope> {
let mut found_scope: Option<&TokenScope> = None;
let token_bytes = token.as_bytes();
for (allow_token, scope) in self.scopes.iter() {
if allow_token.len() == token_bytes.len()
&& allow_token.as_bytes().ct_eq(token_bytes).into()
{
found_scope = Some(scope);
}
}
found_scope
}
pub fn is_dev_mode(&self) -> bool {
self.scopes.is_empty()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TenantConfig {
pub require_header: bool,
}
impl TenantConfig {
pub fn from_env() -> Self {
let require_header = std::env::var(ENV_REQUIRE_TENANT).is_ok_and(|v| v.trim() == "1");
Self { require_header }
}
}
fn envelope(status: StatusCode, kind: &str, message: &str) -> Response {
let body = Json(json!({
"error": { "kind": kind, "message": message }
}));
(status, body).into_response()
}
fn parse_bearer_credentials(value: &str) -> Option<&str> {
if value.bytes().any(|b| b != b'\t' && (b < 0x20 || b == 0x7F)) {
return None;
}
let split = value
.as_bytes()
.iter()
.position(|&b| b == b' ' || b == b'\t')?;
let (scheme, rest) = value.split_at(split);
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
Some(rest.trim_matches(|c: char| c == ' ' || c == '\t'))
}
pub async fn bearer_auth(mut req: Request, next: Next) -> Response {
let cfg = req
.extensions()
.get::<AuthConfig>()
.cloned()
.unwrap_or_default();
if cfg.is_dev_mode() {
if !cfg.dev_mode_allowed {
return envelope(
StatusCode::UNAUTHORIZED,
"unauthorized",
"no bearer tokens configured and dev mode is not enabled; set \
TENSOR_WASM_API_TOKENS to enable bearer auth, or set \
TENSOR_WASM_API_ALLOW_DEV_MODE=1 to allow unauthenticated access",
);
}
req.extensions_mut()
.insert(crate::rate_limit::AuthContext::dev());
return next.run(req).await;
}
let auth_count = req
.headers()
.get_all(axum::http::header::AUTHORIZATION)
.iter()
.count();
if auth_count > 1 {
return envelope(
StatusCode::BAD_REQUEST,
"bad_request",
"duplicate Authorization headers are not allowed",
);
}
let raw_auth = req.headers().get(axum::http::header::AUTHORIZATION);
if let Some(value) = raw_auth {
if value.as_bytes().len() > MAX_AUTH_HEADER_BYTES {
return envelope(
StatusCode::UNAUTHORIZED,
"invalid_auth",
"Authorization header exceeds the maximum permitted length",
);
}
}
let header = raw_auth.and_then(|h| h.to_str().ok());
let token = match header.and_then(parse_bearer_credentials) {
Some(t) if !t.is_empty() => t.to_owned(),
_ => {
return envelope(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing or malformed Authorization: Bearer <token> header",
);
}
};
let scope = match cfg.scope_for(&token) {
Some(s) => s.clone(),
None => {
return envelope(
StatusCode::UNAUTHORIZED,
"unauthorized",
"bearer token is not allowlisted",
);
}
};
req.extensions_mut()
.insert(crate::rate_limit::AuthContext::with_scope(&token, scope));
next.run(req).await
}
#[allow(clippy::result_large_err)]
pub fn extract_tenant(headers: &HeaderMap, cfg: TenantConfig) -> Result<TenantId, Response> {
let header_count = headers.get_all(HEADER_TENANT).iter().count();
if header_count > 1 {
return Err(envelope(
StatusCode::BAD_REQUEST,
"duplicate_tenant_header",
"multiple X-TensorWasm-Tenant headers are not allowed",
));
}
let raw = headers.get(HEADER_TENANT).and_then(|h| h.to_str().ok());
match raw {
None => {
if cfg.require_header {
Err(envelope(
StatusCode::BAD_REQUEST,
"missing_tenant",
"X-TensorWasm-Tenant header is required (TENSOR_WASM_API_REQUIRE_TENANT=1)",
))
} else {
Ok(TenantId(0))
}
}
Some(s) => match s.trim().parse::<u64>() {
Ok(v) => Ok(TenantId(v)),
Err(_) => Err(envelope(
StatusCode::BAD_REQUEST,
"invalid_tenant",
"X-TensorWasm-Tenant must be a u64",
)),
},
}
}
pub async fn tenant_scope(mut req: Request, next: Next) -> Response {
let cfg = req
.extensions()
.get::<TenantConfig>()
.copied()
.unwrap_or_default();
let tenant = match extract_tenant(req.headers(), cfg) {
Ok(t) => t,
Err(resp) => return resp,
};
req.extensions_mut().insert(tenant);
next.run(req).await
}
pub fn trace_layer_with_propagation() -> tower_http::trace::TraceLayer<
tower_http::classify::SharedClassifier<tower_http::classify::ServerErrorsAsFailures>,
impl Fn(&axum::http::Request<axum::body::Body>) -> tracing::Span + Clone,
> {
use tracing_opentelemetry::OpenTelemetrySpanExt;
TraceLayer::new_for_http().make_span_with(|req: &axum::http::Request<axum::body::Body>| {
let raw_tp = req
.headers()
.get("traceparent")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let sanitised_tp = sanitise_traceparent(raw_tp);
let sanitised_path = sanitize_path(req.uri().path());
let normalised_method = normalize_method(req.method().as_str());
let span = tracing::info_span!(
"http.request",
method = %normalised_method,
path = %sanitised_path,
version = ?req.version(),
traceparent = %sanitised_tp,
);
let parent_cx = crate::trace_propagation::extract_parent_context(req.headers());
span.set_parent(parent_cx);
span
})
}
const TRACEPARENT_MAX_BYTES: usize = 64;
const TRACEPARENT_INVALID_SENTINEL: &str = "<invalid>";
fn sanitise_traceparent(raw: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
if raw.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
return Cow::Owned(TRACEPARENT_INVALID_SENTINEL.to_string());
}
let is_clean =
raw.len() <= TRACEPARENT_MAX_BYTES && raw.bytes().all(|b| (0x20..=0x7E).contains(&b));
if is_clean {
return Cow::Borrowed(raw);
}
let mut out = String::with_capacity(raw.len().min(TRACEPARENT_MAX_BYTES));
for ch in raw.chars() {
let b = ch as u32;
if !(0x20..=0x7E).contains(&b) {
continue;
}
let ch_len = ch.len_utf8();
if out.len() + ch_len > TRACEPARENT_MAX_BYTES {
break;
}
out.push(ch);
}
Cow::Owned(out)
}
pub const MAX_PATH_LEN: usize = 256;
const METHOD_OTHER_SENTINEL: &str = "OTHER";
const METHOD_MAX_LEN: usize = 16;
pub fn sanitize_path(raw: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let is_clean = raw.len() <= MAX_PATH_LEN
&& raw
.bytes()
.all(|b| (0x20..=0x7E).contains(&b) && b != b'\r' && b != b'\n' && b != 0);
if is_clean {
return Cow::Borrowed(raw);
}
let mut out = String::with_capacity(raw.len().min(MAX_PATH_LEN));
let mut truncated = false;
for ch in raw.chars() {
if ch == '\r' || ch == '\n' || ch == '\0' {
out.push('?');
if out.len() >= MAX_PATH_LEN {
truncated = true;
break;
}
continue;
}
let b = ch as u32;
let replacement = if (0x20..=0x7E).contains(&b) {
ch
} else {
'?'
};
let ch_len = replacement.len_utf8();
if out.len() + ch_len > MAX_PATH_LEN {
truncated = true;
break;
}
out.push(replacement);
}
if truncated {
out.push('โฆ');
}
Cow::Owned(out)
}
pub fn normalize_method(raw: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let bytes = raw.as_bytes();
let valid = !bytes.is_empty()
&& bytes.len() <= METHOD_MAX_LEN
&& bytes.iter().all(|b| b.is_ascii_uppercase());
if valid {
Cow::Borrowed(raw)
} else {
Cow::Owned(METHOD_OTHER_SENTINEL.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn timeout_layer_constructs() {
let _ = timeout_layer(Duration::from_millis(1));
let _ = timeout_layer(DEFAULT_REQUEST_TIMEOUT);
}
#[test]
fn trace_layer_constructs() {
let _ = trace_layer();
}
#[test]
fn concurrency_limit_layer_constructs() {
let _ = concurrency_limit_layer(1);
let _ = concurrency_limit_layer(DEFAULT_CONCURRENCY_LIMIT);
}
#[test]
fn body_limit_layer_constructs() {
let _ = body_limit_layer(MAX_REQUEST_BODY_BYTES);
}
#[test]
fn cors_config_default_is_empty_allowlist() {
let cfg = CorsConfig::default();
assert!(cfg.allowed_origins.is_empty());
}
#[test]
fn cors_config_from_origins_round_trips() {
let cfg = CorsConfig::from_origins(["https://app.example.com"]);
assert_eq!(cfg.allowed_origins, vec!["https://app.example.com"]);
}
#[test]
fn cors_layer_constructs_empty_allowlist() {
let _ = cors_layer(&CorsConfig::default());
}
#[test]
fn cors_layer_constructs_with_origins() {
let _ = cors_layer(&CorsConfig::from_origins([
"https://app.example.com",
"https://admin.example.com",
]));
}
#[test]
fn trace_layer_with_propagation_constructs() {
let _ = trace_layer_with_propagation();
}
#[test]
fn sanitise_traceparent_passes_through_well_formed_value() {
let sample = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
let out = sanitise_traceparent(sample);
assert_eq!(out.as_ref(), sample);
assert!(
matches!(out, std::borrow::Cow::Borrowed(_)),
"well-formed value must be borrowed, not allocated",
);
}
#[test]
fn sanitise_traceparent_rejects_crlf_injection() {
let attack = "\r\nSET-COOKIE: x=y\r\n";
let out = sanitise_traceparent(attack);
assert_eq!(out.as_ref(), "<invalid>");
}
#[test]
fn sanitise_traceparent_rejects_embedded_nul() {
let attack = "00-aaaa\0bbbb";
let out = sanitise_traceparent(attack);
assert_eq!(out.as_ref(), "<invalid>");
}
#[test]
fn sanitise_traceparent_truncates_oversized_input_to_64_bytes() {
let input = "a".repeat(100);
let out = sanitise_traceparent(&input);
assert_eq!(out.len(), 64);
assert!(out.chars().all(|c| c == 'a'));
}
#[test]
fn sanitise_traceparent_filters_non_printable_bytes() {
let attack = "00-aaaa\x1Bbbbb-cc";
let out = sanitise_traceparent(attack);
assert!(!out.contains('\x1B'));
assert!(out.contains("aaaa"));
assert!(out.contains("bbbb"));
}
#[test]
fn auth_config_dev_mode_when_empty() {
let cfg = AuthConfig::from_tokens(Vec::<String>::new());
assert!(cfg.is_dev_mode());
assert!(!cfg.accepts("anything"));
}
#[test]
fn auth_config_programmatic_constructors_allow_dev_mode() {
assert!(AuthConfig::default().dev_mode_allowed);
assert!(AuthConfig::from_tokens(Vec::<String>::new()).dev_mode_allowed);
assert!(AuthConfig::from_scopes(Vec::<(String, TokenScope)>::new()).dev_mode_allowed);
}
#[test]
fn auth_config_from_env_empty_without_opt_in_forbids_dev_mode() {
temp_env::with_vars(
[
(ENV_API_TOKENS, None::<&str>),
(ENV_ALLOW_DEV_MODE, None::<&str>),
],
|| {
let cfg = AuthConfig::from_env();
assert!(cfg.is_dev_mode());
assert!(
!cfg.dev_mode_allowed,
"empty allowlist + no opt-in must fail closed",
);
},
);
}
#[test]
fn auth_config_from_env_opt_in_values_enable_dev_mode() {
for val in ["1", "true", "TRUE", " true "] {
temp_env::with_vars(
[
(ENV_API_TOKENS, None::<&str>),
(ENV_ALLOW_DEV_MODE, Some(val)),
],
|| {
let cfg = AuthConfig::from_env();
assert!(cfg.is_dev_mode());
assert!(
cfg.dev_mode_allowed,
"ENV_ALLOW_DEV_MODE={val:?} must enable dev mode",
);
},
);
}
}
#[test]
fn auth_config_from_env_non_truthy_opt_in_forbids_dev_mode() {
for val in ["0", "false", "yes", ""] {
temp_env::with_vars(
[
(ENV_API_TOKENS, None::<&str>),
(ENV_ALLOW_DEV_MODE, Some(val)),
],
|| {
assert!(
!AuthConfig::from_env().dev_mode_allowed,
"ENV_ALLOW_DEV_MODE={val:?} must NOT enable dev mode",
);
},
);
}
}
#[test]
fn auth_config_from_env_with_tokens_is_not_dev_mode() {
temp_env::with_vars(
[
(ENV_API_TOKENS, Some("secret:tenant=*")),
(ENV_ALLOW_DEV_MODE, None::<&str>),
],
|| {
let cfg = AuthConfig::from_env();
assert!(!cfg.is_dev_mode());
assert!(cfg.accepts("secret"));
},
);
}
#[tokio::test]
async fn bearer_auth_fails_closed_when_dev_mode_not_allowed() {
use axum::routing::get;
use tower::ServiceExt as _;
let cfg = temp_env::with_vars(
[
(ENV_API_TOKENS, None::<&str>),
(ENV_ALLOW_DEV_MODE, None::<&str>),
],
AuthConfig::from_env,
);
assert!(!cfg.dev_mode_allowed);
let router = axum::Router::new()
.route("/x", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(bearer_auth))
.layer(axum::Extension(cfg));
let resp = router
.oneshot(
Request::builder()
.uri("/x")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bearer_auth_passes_through_when_dev_mode_allowed() {
use axum::routing::get;
use tower::ServiceExt as _;
let router = axum::Router::new()
.route("/x", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(bearer_auth))
.layer(axum::Extension(AuthConfig::default()));
let resp = router
.oneshot(
Request::builder()
.uri("/x")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn auth_config_accepts_matching_token() {
let cfg = AuthConfig::from_tokens(["foo", "bar"]);
assert!(!cfg.is_dev_mode());
assert!(cfg.accepts("foo"));
assert!(cfg.accepts("bar"));
assert!(!cfg.accepts("baz"));
}
#[test]
fn auth_config_from_tokens_defaults_to_wildcard_scope() {
let cfg = AuthConfig::from_tokens(["foo"]);
let scope = cfg.scope_for("foo").expect("scope present");
assert!(
scope.tenants.is_all(),
"from_tokens must default to wildcard"
);
}
#[test]
fn auth_config_from_scopes_round_trips() {
let cfg = AuthConfig::from_scopes([
(
"foo",
crate::token_scope::TokenScope::from_tenants([TenantId(1)]),
),
("bar", crate::token_scope::TokenScope::all()),
]);
assert!(cfg.accepts("foo"));
assert!(cfg.accepts("bar"));
let foo = cfg.scope_for("foo").expect("foo");
assert!(foo.allows(TenantId(1)));
assert!(!foo.allows(TenantId(2)));
let bar = cfg.scope_for("bar").expect("bar");
assert!(bar.allows(TenantId(99)));
}
#[test]
fn extract_tenant_default_zero_when_optional() {
let headers = HeaderMap::new();
let cfg = TenantConfig {
require_header: false,
};
let tid = extract_tenant(&headers, cfg).expect("default to TenantId(0)");
assert_eq!(tid, TenantId(0));
}
#[test]
fn extract_tenant_errors_when_required_and_missing() {
let headers = HeaderMap::new();
let cfg = TenantConfig {
require_header: true,
};
let err = extract_tenant(&headers, cfg).expect_err("required header missing");
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn extract_tenant_parses_header_value() {
let mut headers = HeaderMap::new();
headers.insert(HEADER_TENANT, HeaderValue::from_static("7"));
let tid = extract_tenant(
&headers,
TenantConfig {
require_header: false,
},
)
.expect("parses");
assert_eq!(tid, TenantId(7));
}
#[test]
fn parse_bearer_credentials_accepts_canonical_scheme() {
assert_eq!(parse_bearer_credentials("Bearer abc"), Some("abc"));
}
#[test]
fn parse_bearer_credentials_is_case_insensitive() {
assert_eq!(parse_bearer_credentials("bearer abc"), Some("abc"));
assert_eq!(parse_bearer_credentials("BEARER abc"), Some("abc"));
assert_eq!(parse_bearer_credentials("BeArEr abc"), Some("abc"));
}
#[test]
fn parse_bearer_credentials_accepts_tab_separator() {
assert_eq!(parse_bearer_credentials("Bearer\tabc"), Some("abc"));
}
#[test]
fn parse_bearer_credentials_trims_surrounding_whitespace() {
assert_eq!(parse_bearer_credentials("Bearer abc"), Some("abc"));
assert_eq!(parse_bearer_credentials("Bearer abc "), Some("abc"));
assert_eq!(parse_bearer_credentials("Bearer \t abc \t "), Some("abc"));
}
#[test]
fn parse_bearer_credentials_rejects_other_schemes() {
assert_eq!(parse_bearer_credentials("Basic ZGVhZGJlZWY="), None);
assert_eq!(parse_bearer_credentials("Token abc"), None);
}
#[test]
fn parse_bearer_credentials_returns_none_for_no_whitespace() {
assert_eq!(parse_bearer_credentials("Bearer"), None);
assert_eq!(parse_bearer_credentials(""), None);
}
#[test]
fn parse_bearer_credentials_empty_token_is_some_empty() {
assert_eq!(parse_bearer_credentials("Bearer "), Some(""));
}
#[test]
fn extract_tenant_rejects_garbage_header() {
let mut headers = HeaderMap::new();
headers.insert(HEADER_TENANT, HeaderValue::from_static("not-a-number"));
let err = extract_tenant(
&headers,
TenantConfig {
require_header: false,
},
)
.expect_err("rejects garbage");
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
}