Skip to main content

tensor_wasm_api/
middleware.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Tower middleware helpers: timeouts, concurrency limits, body limits,
5//! authentication, tenant scoping, and tracing spans.
6//!
7//! Each helper returns a single `tower` layer (or middleware function) that
8//! the server module composes into the axum router. Keeping the helpers thin
9//! makes them easy to reuse in integration tests and benchmarks where a custom
10//! stack is desired.
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Duration;
15
16use axum::extract::Request;
17use axum::http::{HeaderMap, StatusCode};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use axum::Json;
21use serde_json::json;
22use subtle::ConstantTimeEq;
23use tensor_wasm_core::types::TenantId;
24use tower::limit::ConcurrencyLimitLayer;
25use tower_http::classify::{ServerErrorsAsFailures, SharedClassifier};
26use tower_http::cors::{AllowOrigin, CorsLayer};
27use tower_http::timeout::TimeoutLayer;
28use tower_http::trace::TraceLayer;
29
30use crate::token_scope::{parse_tokens_env, TokenScope};
31
32/// Default per-request timeout used by [`crate::server::build_router`].
33pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
34
35/// Default process-wide cap on in-flight requests. Retained for callers that
36/// want a single number; production deployments should prefer the per-route
37/// caps below.
38pub const DEFAULT_CONCURRENCY_LIMIT: usize = 64;
39
40/// Per-route concurrency caps (api S-26). A single global semaphore lets a
41/// probe storm starve `/invoke`; per-route caps isolate the budgets.
42///
43/// Probe routes (`/healthz`, `/metrics`) get a generous budget because they
44/// are cheap and a k8s deployment may have many replicas all scraping at
45/// once. Invoke is the heaviest path — keep it tight. Reads and writes get
46/// asymmetric caps because writes tend to compile Wasm and allocate engine
47/// resources.
48pub const PROBE_CONCURRENCY_LIMIT: usize = 256;
49/// Concurrent `/invoke` ceiling. Tighter than the default because invokes
50/// hold a Wasmtime instance lock across `call_async`.
51pub const INVOKE_CONCURRENCY_LIMIT: usize = 32;
52/// Concurrent read-route ceiling (GETs that are not probes).
53pub const READ_CONCURRENCY_LIMIT: usize = 64;
54/// Concurrent write-route ceiling (POST/PUT/DELETE excluding invoke).
55pub const WRITE_CONCURRENCY_LIMIT: usize = 16;
56
57/// Maximum allowed inbound request body size, in bytes. 64 MiB.
58///
59/// Documented in `API.md` under "Request limits". Requests larger than this
60/// are rejected with `413 Payload Too Large` by axum's
61/// [`DefaultBodyLimit`](axum::extract::DefaultBodyLimit) at extract time
62/// (i.e., when a handler reads the body via `Bytes`, `Json`, etc.). We use
63/// axum's native `DefaultBodyLimit::max` rather than
64/// `tower_http::limit::RequestBodyLimitLayer`: the tower-http layer rewraps
65/// the request body in `Limited<Body>`, which breaks composition with
66/// `axum::middleware::from_fn` (bearer auth, tenant scope) downstream, and
67/// `DefaultBodyLimit::max` gives the same 413 contract without the rewrap.
68pub const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
69
70/// Environment variable carrying a comma-separated allowlist of bearer
71/// tokens accepted by [`bearer_auth`]. Empty / unset = dev mode pass-through
72/// (but only when [`ENV_ALLOW_DEV_MODE`] explicitly opts in — see below).
73pub const ENV_API_TOKENS: &str = "TENSOR_WASM_API_TOKENS";
74
75/// Environment variable that explicitly opts the gateway into dev mode
76/// (auth disabled, every request passes through with `AuthContext::dev`).
77///
78/// Accepted truthy values are `"1"` and `"true"` (case-insensitive, trimmed).
79/// Any other value — including unset — leaves dev mode *disabled*.
80///
81/// Closes the M4 finding: an empty / unset [`ENV_API_TOKENS`] used to make
82/// [`bearer_auth`] fail *open* (wildcard pass-through), so a deployment that
83/// merely forgot to populate the allowlist silently accepted every request.
84/// We now fail *closed*: when no tokens are configured AND this opt-in is not
85/// set, [`bearer_auth`] rejects every request with `401 Unauthorized`. The
86/// startup `warn!` in [`AuthConfig::from_env`] is preserved so an operator who
87/// genuinely wants dev mode still sees the loud signal — they just have to
88/// acknowledge it by setting this variable.
89///
90/// Only [`AuthConfig::from_env`] consults this variable. Configs built
91/// programmatically via [`AuthConfig::from_tokens`] / [`AuthConfig::from_scopes`]
92/// / [`AuthConfig::default`] are treated as an explicit in-process opt-in
93/// (they cannot be poisoned by hostile ambient environment), so they preserve
94/// the historical pass-through behaviour for tests and embedders.
95pub const ENV_ALLOW_DEV_MODE: &str = "TENSOR_WASM_API_ALLOW_DEV_MODE";
96
97/// Environment variable carrying a comma-separated allowlist of bearer
98/// tokens that are additionally permitted to call `POST /kernels` (the
99/// kernel-publish scope — see [`KernelPublishTokens`]).
100///
101/// Empty / unset = no token is permitted to publish. In that case
102/// `POST /kernels` returns `403 kernel_publish_scope_required`. The
103/// `/kernels` GET routes are unaffected — every authenticated tenant may
104/// list and resolve.
105///
106/// Each entry must be a raw bearer token string that also appears in
107/// [`ENV_API_TOKENS`] (otherwise [`bearer_auth`] would 401 the request
108/// before scope evaluation runs). Allowlisting a token here that is not
109/// in `TENSOR_WASM_API_TOKENS` is harmless — the request never reaches
110/// the scope gate — but logically nonsensical.
111///
112/// Closes the T1 security finding: previously `POST /kernels` accepted
113/// any allowlisted bearer (or any caller in dev mode), letting a
114/// tenant-1 token publish kernels that every other tenant could then
115/// resolve. The publish gate is now distinct from the API allowlist so
116/// operators can hand out tenant tokens widely while restricting
117/// publish authority to a small set of trusted clients.
118pub const ENV_KERNEL_PUBLISH_TOKENS: &str = "TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS";
119
120/// Maximum byte length permitted for an inbound `Authorization` header
121/// value before [`bearer_auth`] will even attempt to parse it.
122///
123/// Hyper's default cap on the combined header block sits around 16 KiB,
124/// so an attacker can still send a single `Authorization: Bearer <huge>`
125/// value of several KiB. The downstream constant-time comparison in
126/// [`AuthConfig::scope_for`] is `O(token_len)` per allowlisted token,
127/// so unbounded oversized values let a hostile client burn CPU at
128/// roughly `O(num_tokens * token_len)` per request. Capping the value
129/// length here (1 KiB) keeps that cost flat — any legitimate bearer
130/// token in production is well under this limit (JWTs are typically
131/// 500–800 bytes, opaque tokens are far smaller).
132///
133/// A value longer than this returns `401 Unauthorized` with
134/// `kind: "invalid_auth"`. We deliberately reject as `401` rather than
135/// `400` so the response shape stays uniform with other auth failures
136/// (missing header, mismatched token) and an attacker cannot use the
137/// status code to probe the size cap.
138pub const MAX_AUTH_HEADER_BYTES: usize = 1024;
139
140/// Environment variable that, when set to `1`, makes the `X-TensorWasm-Tenant`
141/// header mandatory. Otherwise its absence defaults to tenant `0`.
142pub const ENV_REQUIRE_TENANT: &str = "TENSOR_WASM_API_REQUIRE_TENANT";
143
144/// Name of the header used to scope a request to a tenant.
145pub const HEADER_TENANT: &str = "X-TensorWasm-Tenant";
146
147/// Environment variable that, when set, restricts the set of `Host` header
148/// values the server will accept. Comma-separated list of authority strings
149/// (e.g. `api.example.com,api2.example.com:8443`). Unset = accept any
150/// `Host`, which is the previous behaviour but is unsafe behind a layered
151/// proxy that may pass arbitrary `Host` values through.
152///
153/// Closes api S-30 (lack of Host validation). The check rejects requests
154/// whose `Host` is not in the allowlist with `400 Bad Request`.
155pub const ENV_TRUSTED_HOSTS: &str = "TENSOR_WASM_API_TRUSTED_HOSTS";
156
157/// Parsed `Host` allowlist used by [`host_validate`].
158///
159/// Closes api S-30 (lack of Host validation). The previous implementation
160/// cached the parsed env value in a process-wide `OnceLock`, which made
161/// tests that wanted to vary the allowlist unable to do so (the first test
162/// to touch the cell froze it for every later test in the same process).
163///
164/// Now the allowlist travels through `axum::Extension<TrustedHosts>`:
165/// [`crate::server::build_router_with_audit`] inserts a `from_env()` value
166/// at build time; tests can override by inserting a different value into
167/// the router extensions. Tests that bypass the server builder still get
168/// the env-var fallback (cached per-process in a private `OnceLock` so the
169/// per-request cost stays zero), but an explicit extension always wins.
170///
171/// Precedence: **explicit `axum::Extension<TrustedHosts>` > env-var
172/// fallback (`TENSOR_WASM_API_TRUSTED_HOSTS`)**.
173#[derive(Debug, Clone, Default)]
174pub struct TrustedHosts(Arc<Vec<String>>);
175
176impl TrustedHosts {
177    /// Parse the allowlist from [`ENV_TRUSTED_HOSTS`]. Splits on `,`,
178    /// trims surrounding whitespace, drops empty entries, and lowercases
179    /// each remaining entry for case-insensitive matching. Unset / empty
180    /// env var yields an empty list (= [`Self::allow_all`]).
181    pub fn from_env() -> Self {
182        let raw = std::env::var(ENV_TRUSTED_HOSTS).unwrap_or_default();
183        Self::from_raw(&raw)
184    }
185
186    /// Helper: parse a comma-separated string as if it were the env
187    /// variable. Public for the explicit-construction path used by tests.
188    pub fn from_raw(raw: &str) -> Self {
189        let parsed: Vec<String> = raw
190            .split(',')
191            .map(|s| s.trim().to_ascii_lowercase())
192            .filter(|s| !s.is_empty())
193            .collect();
194        Self(Arc::new(parsed))
195    }
196
197    /// Explicit "no allowlist" constructor — every Host value is admitted
198    /// (the legacy default when the env var is unset).
199    pub fn allow_all() -> Self {
200        Self(Arc::new(Vec::new()))
201    }
202
203    /// Construct from an iterator of allowlist entries. Entries are
204    /// lowercased on insertion so case-insensitive matching in
205    /// [`Self::contains`] is just a byte comparison.
206    pub fn from_hosts<I, S>(iter: I) -> Self
207    where
208        I: IntoIterator<Item = S>,
209        S: Into<String>,
210    {
211        let parsed: Vec<String> = iter
212            .into_iter()
213            .map(|s| s.into().trim().to_ascii_lowercase())
214            .filter(|s| !s.is_empty())
215            .collect();
216        Self(Arc::new(parsed))
217    }
218
219    /// `true` when no entries are configured — every host is admitted.
220    pub fn is_empty(&self) -> bool {
221        self.0.is_empty()
222    }
223
224    /// `true` when the supplied host (raw value from `Host:` or
225    /// `:authority`) matches one of the allowlist entries.
226    ///
227    /// Matching rules:
228    ///
229    /// * **Case-insensitive exact match** on the supplied host.
230    /// * If the supplied host carries a default-port suffix (`:80` or
231    ///   `:443`) and no allowlist entry contains a `:`, the port is
232    ///   stripped before comparison. This lets operators list bare
233    ///   hostnames (`api.example.com`) and still admit clients that
234    ///   include the default port in the `Host` header. If any
235    ///   allowlist entry contains a port, we do an exact match on the
236    ///   full `host:port` string — the operator chose to be specific.
237    pub fn contains(&self, host: &str) -> bool {
238        if self.0.is_empty() {
239            return true;
240        }
241        let host_lc = host.trim().to_ascii_lowercase();
242        if self.0.contains(&host_lc) {
243            return true;
244        }
245        // Default-port strip: only apply when no allowlist entry carries
246        // a port (otherwise the operator's port-bound entry must match
247        // exactly).
248        let allow_has_port = self.0.iter().any(|a| a.contains(':'));
249        if allow_has_port {
250            return false;
251        }
252        if let Some(stripped) = strip_default_port(&host_lc) {
253            return self.0.iter().any(|allowed| allowed == stripped);
254        }
255        false
256    }
257}
258
259/// Strip a trailing `:80` or `:443` from a (already-lowercased) host
260/// string. Returns `None` if no default-port suffix is present.
261fn strip_default_port(host_lc: &str) -> Option<&str> {
262    for suffix in [":443", ":80"] {
263        if let Some(stripped) = host_lc.strip_suffix(suffix) {
264            return Some(stripped);
265        }
266    }
267    None
268}
269
270/// Per-process cached env-var fallback for [`host_validate`] when no
271/// `axum::Extension<TrustedHosts>` is present. Tests that drive the
272/// middleware through the server builder always get an explicit
273/// extension and never touch this; tests that bypass the builder
274/// (e.g. wrap `host_validate` with `axum::middleware::from_fn` directly)
275/// see the env value parsed once.
276fn env_trusted_hosts_fallback() -> TrustedHosts {
277    static ONCE: std::sync::OnceLock<TrustedHosts> = std::sync::OnceLock::new();
278    ONCE.get_or_init(TrustedHosts::from_env).clone()
279}
280
281/// Allowlist of bearer tokens permitted to call `POST /kernels`.
282///
283/// Internally stores the [`crate::rate_limit::TokenId`] of each
284/// allowlisted bearer (the `xxhash64` digest used elsewhere for rate
285/// limiting), NOT the raw token string. Keeping `TokenId`s here means
286/// the value can flow through `axum::Extension` and tracing without
287/// risking the raw secret leaking into a span attribute or audit
288/// record, and `allows()` reduces to a hash-set lookup.
289///
290/// The list is empty by default — that is the safe posture: with no
291/// publish-scoped tokens configured, `POST /kernels` returns
292/// `403 kernel_publish_scope_required`. Dev mode (empty
293/// `TENSOR_WASM_API_TOKENS`) is even stricter: the publish handler
294/// rejects outright with `kernel_publish_disabled_in_dev_mode` to deny
295/// the unauthenticated-attacker path the security review flagged.
296///
297/// Sourced from [`ENV_KERNEL_PUBLISH_TOKENS`] at server startup; tests
298/// drive it directly via [`KernelPublishTokens::from_tokens`] so they
299/// do not poison the process environment.
300#[derive(Debug, Clone, Default)]
301pub struct KernelPublishTokens {
302    /// Stable token identifiers permitted to publish kernels. Stored as
303    /// `Arc<HashSet<...>>` so cheap clones into request extensions and
304    /// per-request reads stay allocation-free.
305    token_ids: Arc<std::collections::HashSet<crate::rate_limit::TokenId>>,
306}
307
308impl KernelPublishTokens {
309    /// Parse the allowlist from [`ENV_KERNEL_PUBLISH_TOKENS`]. Splits on
310    /// `,`, trims surrounding whitespace, drops empty entries, hashes
311    /// each remaining entry into a [`crate::rate_limit::TokenId`]. Unset
312    /// / empty env var yields the empty allowlist — every `POST
313    /// /kernels` is rejected with `kernel_publish_scope_required`.
314    pub fn from_env() -> Self {
315        let raw = std::env::var(ENV_KERNEL_PUBLISH_TOKENS).unwrap_or_default();
316        Self::from_raw(&raw)
317    }
318
319    /// Parse a comma-separated string as if it were the env variable.
320    /// Public for the explicit-construction path used by tests.
321    pub fn from_raw(raw: &str) -> Self {
322        let token_ids = raw
323            .split(',')
324            .map(str::trim)
325            .filter(|s| !s.is_empty())
326            .map(crate::rate_limit::TokenId::from_bearer)
327            .collect();
328        Self {
329            token_ids: Arc::new(token_ids),
330        }
331    }
332
333    /// Construct from an iterator of raw bearer-token strings. Used by
334    /// integration tests that drive the publish-scope path directly.
335    pub fn from_tokens<I, S>(iter: I) -> Self
336    where
337        I: IntoIterator<Item = S>,
338        S: AsRef<str>,
339    {
340        let token_ids = iter
341            .into_iter()
342            .map(|s| crate::rate_limit::TokenId::from_bearer(s.as_ref()))
343            .collect();
344        Self {
345            token_ids: Arc::new(token_ids),
346        }
347    }
348
349    /// `true` when no entries are configured. With an empty allowlist
350    /// the kernel-publish gate denies every `POST /kernels` — the safe
351    /// default.
352    pub fn is_empty(&self) -> bool {
353        self.token_ids.is_empty()
354    }
355
356    /// `true` when `token_id` is permitted to publish kernels.
357    pub fn allows(&self, token_id: crate::rate_limit::TokenId) -> bool {
358        self.token_ids.contains(&token_id)
359    }
360}
361
362/// Middleware: reject requests whose `Host` header (or HTTP/2
363/// `:authority` pseudo-header) is not in the configured allowlist.
364///
365/// Source of truth for the allowlist:
366///
367/// 1. `axum::Extension<TrustedHosts>` if present on the request — the
368///    server builder inserts this from
369///    [`TrustedHosts::from_env`] at startup, and tests can override.
370/// 2. Otherwise, a per-process env-var fallback parsed once from
371///    `TENSOR_WASM_API_TRUSTED_HOSTS`.
372///
373/// Empty allowlist (no entries / env unset) = pass-through.
374///
375/// Host extraction order:
376///
377/// 1. `Host:` request header.
378/// 2. If absent, `req.uri().authority()` — the URI carries the HTTP/2
379///    `:authority` pseudo-header in `hyper`'s normalised request form.
380/// 3. If both absent and the allowlist is non-empty, respond `400`.
381///
382/// Closes api S-30. T19 perf re-order: layered OUTSIDE the trace layer
383/// (and the CORS layer) so hostile Host probes are rejected before any
384/// trace span is allocated or any `traceparent` propagator hop runs.
385/// Still layered BEFORE bearer_auth so an attacker probing for valid
386/// hosts cannot also probe for valid tokens. The probe router inherits
387/// this gate too; operators with split-Host probes can simply omit the
388/// env var. Rejected requests do NOT get a trace span and are NOT
389/// counted by the HTTP metrics layer (which sits inside the trace
390/// layer) — an intentional tradeoff so a hostile Host probe storm
391/// cannot burn either trace budget or metric cardinality. See the
392/// comment block in `build_router_full`.
393pub async fn host_validate(req: Request, next: Next) -> Response {
394    let allow = req
395        .extensions()
396        .get::<TrustedHosts>()
397        .cloned()
398        .unwrap_or_else(env_trusted_hosts_fallback);
399    if allow.is_empty() {
400        return next.run(req).await;
401    }
402    // 1) Try the `Host:` header first (HTTP/1.1 canonical path).
403    let host_header = req
404        .headers()
405        .get(axum::http::header::HOST)
406        .and_then(|h| h.to_str().ok())
407        .map(|s| s.to_owned());
408    // 2) Fall back to the URI authority (HTTP/2 `:authority` pseudo-header
409    //    surfaces here in hyper's normalised request form).
410    let authority = req.uri().authority().map(|a| a.as_str().to_owned());
411    let host = host_header.or(authority);
412
413    match host {
414        Some(h) if allow.contains(&h) => next.run(req).await,
415        _ => envelope(
416            StatusCode::BAD_REQUEST,
417            "bad_request",
418            "Host header missing or not in TENSOR_WASM_API_TRUSTED_HOSTS allowlist",
419        ),
420    }
421}
422
423/// Build a per-request timeout layer.
424///
425/// Requests that exceed `d` are aborted with `408 Request Timeout`.
426pub fn timeout_layer(d: Duration) -> TimeoutLayer {
427    TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, d)
428}
429
430/// Build the default HTTP tracing layer.
431///
432/// Emits a `tracing` span per request capturing method, URI, and response
433/// status. The classifier treats `5xx` responses as failures.
434pub fn trace_layer() -> TraceLayer<SharedClassifier<ServerErrorsAsFailures>> {
435    TraceLayer::new_for_http()
436}
437
438/// Build a process-wide concurrency limit layer that allows at most `max`
439/// in-flight requests.
440pub fn concurrency_limit_layer(max: usize) -> ConcurrencyLimitLayer {
441    ConcurrencyLimitLayer::new(max)
442}
443
444/// Build the global request-body size cap (64 MiB by default).
445///
446/// Returns `413 Payload Too Large` for any body that exceeds `max_bytes`
447/// when a handler tries to extract the body. See [`MAX_REQUEST_BODY_BYTES`]
448/// for the rationale on using axum's native limit rather than tower-http's.
449pub fn body_limit_layer(max_bytes: usize) -> axum::extract::DefaultBodyLimit {
450    axum::extract::DefaultBodyLimit::max(max_bytes)
451}
452
453/// Environment variable carrying a comma-separated allowlist of origins
454/// permitted for cross-origin requests. Empty / unset = reject all
455/// cross-origin requests.
456pub const ENV_CORS_ALLOWED_ORIGINS: &str = "TENSOR_WASM_API_CORS_ALLOWED_ORIGINS";
457
458/// HTTP headers permitted on cross-origin requests. Covers the standard
459/// `Authorization` and `Content-Type`, the TensorWasm tenant header used to
460/// scope per-tenant calls, and the W3C `traceparent` header so browser
461/// callers can stitch their own trace context into the gateway's spans.
462const CORS_ALLOWED_HEADERS: &[&str] = &[
463    "authorization",
464    "content-type",
465    "x-tensorwasm-tenant",
466    "traceparent",
467];
468
469/// Cross-origin policy snapshot loaded from the process environment.
470///
471/// `allowed_origins` is the explicit allowlist of cross-origin browser
472/// callers that may reach the API. The default is empty — i.e.
473/// **cross-origin requests are rejected** until the operator widens the
474/// allowlist. This matches the gateway's other security defaults
475/// (`TENSOR_WASM_API_TOKENS` dev mode is the only opt-out).
476///
477/// To widen, list one origin per entry exactly as the browser sends the
478/// `Origin` header (scheme + host + optional port), comma-separated:
479///
480/// ```text
481/// TENSOR_WASM_API_CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
482/// ```
483#[derive(Debug, Clone, Default)]
484pub struct CorsConfig {
485    /// Origins permitted for cross-origin requests. Empty = reject all.
486    pub allowed_origins: Vec<String>,
487}
488
489impl CorsConfig {
490    /// Load the allowlist from `$TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`.
491    /// Unset or empty = reject all cross-origin requests.
492    pub fn from_env() -> Self {
493        let raw = std::env::var(ENV_CORS_ALLOWED_ORIGINS).unwrap_or_default();
494        let allowed_origins: Vec<String> = raw
495            .split(',')
496            .map(|s| s.trim().to_owned())
497            .filter(|s| !s.is_empty())
498            .collect();
499        Self { allowed_origins }
500    }
501
502    /// Construct directly from an explicit list of origins. The empty list
503    /// yields the safe default (no cross-origin requests admitted).
504    pub fn from_origins<I, S>(iter: I) -> Self
505    where
506        I: IntoIterator<Item = S>,
507        S: Into<String>,
508    {
509        Self {
510            allowed_origins: iter.into_iter().map(Into::into).collect(),
511        }
512    }
513}
514
515/// Build the CORS layer for the gateway router.
516///
517/// * Empty allowlist (`cfg.allowed_origins.is_empty()`) → returns
518///   `CorsLayer::new()`, which sets no `Access-Control-Allow-Origin` header
519///   and therefore rejects every cross-origin request. This is the safe
520///   default for fresh installs — operators opt in by setting
521///   `TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`.
522/// * Non-empty allowlist → returns a layer that admits exactly those
523///   origins (parsed back into `HeaderValue`s; unparseable entries are
524///   silently dropped — they were already rejected at startup by the
525///   bearer-auth allowlist parser's stricter sibling and would never match
526///   a real browser `Origin` header anyway).
527///
528/// The allowed methods (`GET`, `POST`, `DELETE`) and headers
529/// (`Authorization`, `Content-Type`, `X-TensorWasm-Tenant`, `Traceparent`)
530/// match the API's wire surface — see `API.md`.
531pub fn cors_layer(cfg: &CorsConfig) -> CorsLayer {
532    use axum::http::Method;
533    // Cover every method the gateway routes use: `GET` (healthz, metrics,
534    // job poll), `POST` (deploy, invoke, invoke-async), and `DELETE`
535    // (function tear-down).
536    let allowed_methods = [Method::GET, Method::POST, Method::DELETE];
537    let base = CorsLayer::new()
538        .allow_methods(allowed_methods)
539        .allow_headers(
540            CORS_ALLOWED_HEADERS
541                .iter()
542                .filter_map(|h| h.parse::<axum::http::HeaderName>().ok())
543                .collect::<Vec<_>>(),
544        );
545
546    if cfg.allowed_origins.is_empty() {
547        // No origins configured — `CorsLayer::new()` admits no origins, so
548        // no cross-origin browser caller will see an
549        // `Access-Control-Allow-Origin` header and the request is rejected
550        // by the browser's preflight check.
551        base
552    } else {
553        let parsed: Vec<axum::http::HeaderValue> = cfg
554            .allowed_origins
555            .iter()
556            .filter_map(|origin| origin.parse::<axum::http::HeaderValue>().ok())
557            .collect();
558        base.allow_origin(AllowOrigin::list(parsed))
559    }
560}
561
562/// Snapshot of authentication configuration loaded from the process
563/// environment at server start. Cloned cheaply into each request.
564///
565/// `scopes` is empty in dev mode (no `TENSOR_WASM_API_TOKENS` set or env
566/// empty), in which case [`bearer_auth`] passes every request through
567/// unchecked. Otherwise each allowlisted bearer token maps to the
568/// [`TokenScope`] that came out of [`parse_tokens_env`] at startup.
569#[derive(Debug, Clone)]
570pub struct AuthConfig {
571    /// Allowlisted bearer tokens → tenant scope. Empty = dev mode
572    /// (pass-through with startup warning).
573    pub scopes: Arc<HashMap<String, TokenScope>>,
574    /// Count of entries that used the legacy bare-token shape. The server
575    /// emits a single deprecation warning at startup if this is nonzero.
576    pub deprecated_count: usize,
577    /// Whether dev-mode pass-through is permitted when `scopes` is empty.
578    ///
579    /// Only meaningful in dev mode (`scopes.is_empty()`). When `false`,
580    /// [`bearer_auth`] fails *closed* and rejects every request with
581    /// `401 Unauthorized` rather than passing it through with
582    /// `AuthContext::dev`. Set from [`ENV_ALLOW_DEV_MODE`] by
583    /// [`AuthConfig::from_env`]; defaults to `true` for programmatic
584    /// constructors (see [`ENV_ALLOW_DEV_MODE`] for the rationale).
585    pub dev_mode_allowed: bool,
586}
587
588impl Default for AuthConfig {
589    /// An empty allowlist with dev mode *allowed*. Programmatic construction
590    /// is an explicit in-process opt-in, so it preserves the historical
591    /// pass-through behaviour (the [`ENV_ALLOW_DEV_MODE`] gate applies only to
592    /// the env-driven [`AuthConfig::from_env`] path).
593    fn default() -> Self {
594        Self {
595            scopes: Arc::new(HashMap::new()),
596            deprecated_count: 0,
597            dev_mode_allowed: true,
598        }
599    }
600}
601
602impl AuthConfig {
603    /// Load the allowlist from `$TENSOR_WASM_API_TOKENS`. Unset or empty
604    /// means "no auth" (dev mode). Logs a one-shot warning in dev mode and
605    /// a one-shot deprecation warning whenever any legacy bare entries were
606    /// observed.
607    pub fn from_env() -> Self {
608        let raw = std::env::var(ENV_API_TOKENS).unwrap_or_default();
609        let parsed = parse_tokens_env(&raw);
610        // M4: empty allowlist is only honoured as dev-mode pass-through when
611        // the operator explicitly opts in via ENV_ALLOW_DEV_MODE. Otherwise we
612        // fail closed (bearer_auth 401s every request). Accept `1` / `true`
613        // (case-insensitive, trimmed) as the truthy set; anything else (incl.
614        // unset) leaves dev mode disabled.
615        let dev_mode_allowed = std::env::var(ENV_ALLOW_DEV_MODE).is_ok_and(|v| {
616            let v = v.trim();
617            v == "1" || v.eq_ignore_ascii_case("true")
618        });
619        if parsed.token_scopes.is_empty() {
620            tracing::warn!(
621                target: "tensor_wasm_api::middleware",
622                env = ENV_API_TOKENS,
623                "TENSOR_WASM_API_TOKENS empty; API accepts all requests (dev mode)",
624            );
625            if !dev_mode_allowed {
626                tracing::warn!(
627                    target: "tensor_wasm_api::middleware",
628                    env = ENV_ALLOW_DEV_MODE,
629                    "{} not set; refusing dev-mode pass-through — every request \
630                     will be rejected with 401. Configure {} to enable bearer \
631                     auth, or set {}=1 to explicitly acknowledge an open gateway",
632                    ENV_ALLOW_DEV_MODE,
633                    ENV_API_TOKENS,
634                    ENV_ALLOW_DEV_MODE,
635                );
636            }
637        }
638        if parsed.deprecated_count > 0 {
639            tracing::warn!(
640                target: "tensor_wasm_api::middleware",
641                env = ENV_API_TOKENS,
642                count = parsed.deprecated_count,
643                "bare bearer tokens in {} are deprecated; switch to \
644                 `token:tenant=...` (or `token:tenant=*` for the current \
645                 wildcard behaviour) — bare entries are scheduled for \
646                 removal in v1.0",
647                ENV_API_TOKENS,
648            );
649        }
650        Self {
651            scopes: Arc::new(parsed.token_scopes),
652            deprecated_count: parsed.deprecated_count,
653            dev_mode_allowed,
654        }
655    }
656
657    /// Construct directly from an explicit allowlist. Each token gets the
658    /// wildcard scope — preserves backwards-compatible behaviour for tests
659    /// that pre-date scoped tokens. For tests that need a non-wildcard
660    /// scope, build the map directly or use [`AuthConfig::from_scopes`].
661    pub fn from_tokens<I, S>(iter: I) -> Self
662    where
663        I: IntoIterator<Item = S>,
664        S: Into<String>,
665    {
666        let mut scopes: HashMap<String, TokenScope> = HashMap::new();
667        for s in iter {
668            scopes.insert(s.into(), TokenScope::all());
669        }
670        Self {
671            scopes: Arc::new(scopes),
672            deprecated_count: 0,
673            dev_mode_allowed: true,
674        }
675    }
676
677    /// Construct from an explicit token → scope map. Used by integration
678    /// tests that drive scoped-token paths directly.
679    pub fn from_scopes<I, S>(iter: I) -> Self
680    where
681        I: IntoIterator<Item = (S, TokenScope)>,
682        S: Into<String>,
683    {
684        let mut scopes: HashMap<String, TokenScope> = HashMap::new();
685        for (k, v) in iter {
686            scopes.insert(k.into(), v);
687        }
688        Self {
689            scopes: Arc::new(scopes),
690            deprecated_count: 0,
691            dev_mode_allowed: true,
692        }
693    }
694
695    /// `true` if the supplied bearer token is allowlisted.
696    ///
697    /// Uses a constant-time byte comparison against every allowlisted entry
698    /// so the time taken to reject a bad token does not leak which prefix
699    /// (if any) matched an allowlist entry. Delegates to `scope_for`.
700    pub fn accepts(&self, token: &str) -> bool {
701        self.scope_for(token).is_some()
702    }
703
704    /// Resolve `token` to its [`TokenScope`] if allowlisted.
705    ///
706    /// Iterates the full allowlist and uses [`subtle::ConstantTimeEq`] for
707    /// each entry rather than a `HashMap::get`. The hash-table lookup
708    /// short-circuits on hash mismatch and then bytes-eq matching entries,
709    /// which is timing-leakable for token discovery — an attacker can
710    /// measure how long the gateway took to reject a candidate token and
711    /// infer how close it got to a real entry. The loop runs over every
712    /// allowlist entry on every call, and we deliberately do NOT `break`
713    /// after a hit so the wall-clock cost is constant w.r.t. the matched
714    /// entry's position. Hashing still happens internally (`scopes` is an
715    /// `Arc<HashMap>` for cheap clones) but the lookup path is no longer
716    /// hash-keyed; the map is used purely as a `(token, scope)` store here.
717    pub fn scope_for(&self, token: &str) -> Option<&TokenScope> {
718        let mut found_scope: Option<&TokenScope> = None;
719        let token_bytes = token.as_bytes();
720        for (allow_token, scope) in self.scopes.iter() {
721            // ct_eq requires equal-length inputs; mismatched lengths cannot
722            // be equal so we skip them. The length itself is not secret —
723            // the operator's `TENSOR_WASM_API_TOKENS` allowlist is fixed at
724            // startup and its lengths are observable through other means.
725            if allow_token.len() == token_bytes.len()
726                && allow_token.as_bytes().ct_eq(token_bytes).into()
727            {
728                found_scope = Some(scope);
729                // Intentionally NOT `break` — keep iterating so the loop
730                // time is constant w.r.t. the matched entry's position.
731            }
732        }
733        found_scope
734    }
735
736    /// `true` if no allowlist was configured (dev mode).
737    pub fn is_dev_mode(&self) -> bool {
738        self.scopes.is_empty()
739    }
740}
741
742/// Snapshot of tenant-header policy loaded from the process environment.
743#[derive(Debug, Clone, Copy, Default)]
744pub struct TenantConfig {
745    /// `true` when `TENSOR_WASM_API_REQUIRE_TENANT=1` was set at startup.
746    pub require_header: bool,
747}
748
749impl TenantConfig {
750    /// Load policy from `$TENSOR_WASM_API_REQUIRE_TENANT` (`"1"` = required).
751    pub fn from_env() -> Self {
752        let require_header = std::env::var(ENV_REQUIRE_TENANT).is_ok_and(|v| v.trim() == "1");
753        Self { require_header }
754    }
755}
756
757/// Render the standard `{ "error": { "kind": ..., "message": ... } }`
758/// envelope at `status`. Shared helper for middleware that cannot import
759/// `crate::routes::ApiError` without a cycle.
760fn envelope(status: StatusCode, kind: &str, message: &str) -> Response {
761    let body = Json(json!({
762        "error": { "kind": kind, "message": message }
763    }));
764    (status, body).into_response()
765}
766
767/// Parse the credentials portion of an `Authorization` header value when the
768/// scheme matches `Bearer` case-insensitively (RFC 6750 §2.1 / RFC 7235 §2.1).
769///
770/// The header value is split on the first run of whitespace separating the
771/// scheme token from the credentials. The scheme is compared via
772/// `eq_ignore_ascii_case("bearer")` so `Bearer`, `bearer`, and `BEARER`
773/// (the latter two emerge from upstream load balancers that normalise
774/// header names/values) all match. Both space and horizontal tab are
775/// accepted as separators (RFC 7235 BWS = bad whitespace). The trailing
776/// credentials are trimmed of surrounding ASCII whitespace before being
777/// returned.
778///
779/// Returns `None` when the value has no whitespace (i.e. is a single token
780/// such as `Bearer`) or the scheme is not bearer. An empty-credential
781/// case (e.g. `"Bearer   "`) returns `Some("")` so the caller can still
782/// enforce its empty-token rejection rule.
783///
784/// **Control-byte defence.** RFC 7230 §3.2.6 bans control characters
785/// (other than horizontal tab) from header field values, and
786/// [`axum::http::HeaderValue`] already rejects NUL/CR/LF at construction.
787/// The `to_str()` conversion in [`bearer_auth`] further restricts the
788/// input to "visible ASCII" plus tab. We nonetheless apply an explicit
789/// belt-and-braces check here so any future refactor that fans this
790/// helper out behind a more permissive byte source cannot silently
791/// re-open a CRLF/NUL injection channel into downstream consumers of
792/// the returned token (audit log fields, span attributes, etc.).
793fn parse_bearer_credentials(value: &str) -> Option<&str> {
794    // Defence in depth: reject the entire value if any control byte
795    // (other than the horizontal tab we explicitly use as a separator)
796    // is present. Covers NUL (C-string truncation), CR/LF (log-line
797    // forgery), and the DEL byte (terminal-escape smuggling).
798    if value.bytes().any(|b| b != b'\t' && (b < 0x20 || b == 0x7F)) {
799        return None;
800    }
801    // Find the first whitespace byte (space or tab) — anything else
802    // separating scheme from credentials would itself be a protocol
803    // violation, so we don't bother with general Unicode whitespace.
804    let split = value
805        .as_bytes()
806        .iter()
807        .position(|&b| b == b' ' || b == b'\t')?;
808    let (scheme, rest) = value.split_at(split);
809    if !scheme.eq_ignore_ascii_case("bearer") {
810        return None;
811    }
812    // Trim the leading whitespace run (BWS) plus any trailing whitespace
813    // around the credentials. `trim_matches` over the BWS set keeps the
814    // behaviour aligned with `str::trim`'s ASCII-whitespace semantics.
815    Some(rest.trim_matches(|c: char| c == ' ' || c == '\t'))
816}
817
818/// Bearer-token authentication middleware.
819///
820/// If the allowlist is empty the request passes through (dev mode — already
821/// warned at startup) and a synthetic `AuthContext::dev` is inserted into
822/// the request extensions. Otherwise the `Authorization: Bearer <token>`
823/// header must match one of the allowlisted tokens; missing or mismatched
824/// headers produce `401 Unauthorized` with the standard error envelope and
825/// `kind: "unauthorized"`. On success an `AuthContext` keyed by the
826/// stable [`crate::rate_limit::TokenId`] derived from the bearer token is
827/// inserted into the request extensions for downstream middleware (rate
828/// limiting, audit) to consume.
829pub async fn bearer_auth(mut req: Request, next: Next) -> Response {
830    let cfg = req
831        .extensions()
832        .get::<AuthConfig>()
833        .cloned()
834        .unwrap_or_default();
835
836    if cfg.is_dev_mode() {
837        // M4: fail closed unless dev mode was explicitly opted into. An empty
838        // allowlist that nobody acknowledged is treated as a misconfiguration,
839        // not as "auth disabled" — every request is rejected rather than
840        // silently granted the wildcard `AuthContext::dev` scope.
841        if !cfg.dev_mode_allowed {
842            return envelope(
843                StatusCode::UNAUTHORIZED,
844                "unauthorized",
845                "no bearer tokens configured and dev mode is not enabled; set \
846                 TENSOR_WASM_API_TOKENS to enable bearer auth, or set \
847                 TENSOR_WASM_API_ALLOW_DEV_MODE=1 to allow unauthenticated access",
848            );
849        }
850        req.extensions_mut()
851            .insert(crate::rate_limit::AuthContext::dev());
852        return next.run(req).await;
853    }
854
855    // api S-3: refuse requests with more than one `Authorization` header.
856    // `HeaderMap::get` returns the first occurrence — a buggy or hostile
857    // client sending two headers would silently get one accepted and the
858    // other invisible. Some proxies also concatenate duplicates with
859    // commas, which would round-trip through `parse_bearer_credentials`
860    // unpredictably. Reject outright.
861    let auth_count = req
862        .headers()
863        .get_all(axum::http::header::AUTHORIZATION)
864        .iter()
865        .count();
866    if auth_count > 1 {
867        return envelope(
868            StatusCode::BAD_REQUEST,
869            "bad_request",
870            "duplicate Authorization headers are not allowed",
871        );
872    }
873    // api header-hardening: cap the inbound Authorization value length
874    // BEFORE the constant-time compare loop runs. Hyper's default cap on
875    // the header block (~16 KiB) is large enough that a single oversized
876    // `Authorization: Bearer <huge>` would still burn CPU through every
877    // `ct_eq` iteration. See [`MAX_AUTH_HEADER_BYTES`] for the rationale.
878    // We inspect the raw bytes so a non-UTF-8 value (rejected by
879    // `to_str()` below) is bounded too.
880    let raw_auth = req.headers().get(axum::http::header::AUTHORIZATION);
881    if let Some(value) = raw_auth {
882        if value.as_bytes().len() > MAX_AUTH_HEADER_BYTES {
883            return envelope(
884                StatusCode::UNAUTHORIZED,
885                "invalid_auth",
886                "Authorization header exceeds the maximum permitted length",
887            );
888        }
889    }
890
891    let header = raw_auth.and_then(|h| h.to_str().ok());
892
893    let token = match header.and_then(parse_bearer_credentials) {
894        Some(t) if !t.is_empty() => t.to_owned(),
895        _ => {
896            return envelope(
897                StatusCode::UNAUTHORIZED,
898                "unauthorized",
899                "missing or malformed Authorization: Bearer <token> header",
900            );
901        }
902    };
903
904    let scope = match cfg.scope_for(&token) {
905        Some(s) => s.clone(),
906        None => {
907            return envelope(
908                StatusCode::UNAUTHORIZED,
909                "unauthorized",
910                "bearer token is not allowlisted",
911            );
912        }
913    };
914
915    req.extensions_mut()
916        .insert(crate::rate_limit::AuthContext::with_scope(&token, scope));
917    next.run(req).await
918}
919
920/// Parse the `X-TensorWasm-Tenant` header into a `TenantId`, applying the
921/// configured policy.
922///
923/// Outcomes:
924///
925/// * **More than one `X-TensorWasm-Tenant` header present** => `Err(400
926///   duplicate_tenant_header)`. `HeaderMap::get` returns only the first
927///   match, so an attacker behind a permissive proxy could send
928///   `X-TensorWasm-Tenant: 1, X-TensorWasm-Tenant: 999` and confuse
929///   downstream observers about which tenant the request really
930///   belongs to. We reject outright before any single value is read.
931/// * **Header absent + `TENSOR_WASM_API_REQUIRE_TENANT=1`** => `Err(400
932///   missing_tenant)`.
933/// * **Header absent otherwise** => `Ok(TenantId(0))`.
934/// * **Header present but not a `u64`** => `Err(400 invalid_tenant)`.
935///   The distinct `invalid_tenant` kind separates a malformed value
936///   from the legitimately-absent case so dashboards can alert on each
937///   class independently — a spike in `invalid_tenant` typically
938///   indicates a client bug or a probing attacker, whereas a spike in
939///   `missing_tenant` indicates a misconfigured client.
940#[allow(clippy::result_large_err)]
941pub fn extract_tenant(headers: &HeaderMap, cfg: TenantConfig) -> Result<TenantId, Response> {
942    // Fix 1: refuse requests carrying more than one X-TensorWasm-Tenant
943    // header. The single-`get` path would otherwise pick the first
944    // occurrence silently while a downstream observer sees the second.
945    let header_count = headers.get_all(HEADER_TENANT).iter().count();
946    if header_count > 1 {
947        return Err(envelope(
948            StatusCode::BAD_REQUEST,
949            "duplicate_tenant_header",
950            "multiple X-TensorWasm-Tenant headers are not allowed",
951        ));
952    }
953    let raw = headers.get(HEADER_TENANT).and_then(|h| h.to_str().ok());
954    match raw {
955        None => {
956            if cfg.require_header {
957                Err(envelope(
958                    StatusCode::BAD_REQUEST,
959                    "missing_tenant",
960                    "X-TensorWasm-Tenant header is required (TENSOR_WASM_API_REQUIRE_TENANT=1)",
961                ))
962            } else {
963                Ok(TenantId(0))
964            }
965        }
966        Some(s) => match s.trim().parse::<u64>() {
967            Ok(v) => Ok(TenantId(v)),
968            // Fix 2: the header is PRESENT but unparseable — emit
969            // `invalid_tenant`, distinct from the absent-and-required
970            // `missing_tenant` case above.
971            Err(_) => Err(envelope(
972                StatusCode::BAD_REQUEST,
973                "invalid_tenant",
974                "X-TensorWasm-Tenant must be a u64",
975            )),
976        },
977    }
978}
979
980/// Middleware that resolves the tenant from `X-TensorWasm-Tenant` and stores it
981/// in the request's [`axum::http::Extensions`] for handlers to pick up via
982/// `Extension<TenantId>`. On parse failure / required-but-missing, emits
983/// the standard error envelope and short-circuits the chain.
984pub async fn tenant_scope(mut req: Request, next: Next) -> Response {
985    let cfg = req
986        .extensions()
987        .get::<TenantConfig>()
988        .copied()
989        .unwrap_or_default();
990
991    let tenant = match extract_tenant(req.headers(), cfg) {
992        Ok(t) => t,
993        Err(resp) => return resp,
994    };
995
996    req.extensions_mut().insert(tenant);
997    next.run(req).await
998}
999
1000/// Returns a [`TraceLayer`] that, in addition to the per-request span,
1001/// reads the W3C `traceparent` header from the incoming request and uses
1002/// it as the parent context for the resulting span.
1003///
1004/// When a downstream service (or load test client) sends the W3C standard
1005/// header, traces stitch correctly across the boundary. When no header is
1006/// present, the span is parented to the local context as usual.
1007///
1008/// The function delegates the actual parsing to the OpenTelemetry global
1009/// `TextMapPropagator`. [`crate::server::build_router_with_audit`] calls
1010/// [`crate::trace_propagation::install_w3c_propagator`] before any
1011/// request can hit this layer, so the parsing path is reliably wired
1012/// regardless of whether the `otlp` feature is enabled on
1013/// `tensor-wasm-core`. If for some reason no propagator is installed —
1014/// e.g. in a test that bypasses the server builder — the extraction
1015/// returns an empty context and the span is parented locally.
1016pub fn trace_layer_with_propagation() -> tower_http::trace::TraceLayer<
1017    tower_http::classify::SharedClassifier<tower_http::classify::ServerErrorsAsFailures>,
1018    impl Fn(&axum::http::Request<axum::body::Body>) -> tracing::Span + Clone,
1019> {
1020    use tracing_opentelemetry::OpenTelemetrySpanExt;
1021
1022    TraceLayer::new_for_http().make_span_with(|req: &axum::http::Request<axum::body::Body>| {
1023        // Surface the raw traceparent value as a span field for log-based
1024        // correlation, even when no OTel propagator is installed. The
1025        // header is sanitised first (see `sanitise_traceparent`) so a
1026        // hostile client cannot smuggle CR/LF, NUL, or megabytes of
1027        // arbitrary text into every log line that touches this request.
1028        let raw_tp = req
1029            .headers()
1030            .get("traceparent")
1031            .and_then(|v| v.to_str().ok())
1032            .unwrap_or("");
1033        let sanitised_tp = sanitise_traceparent(raw_tp);
1034
1035        // Record only the URI **path**, never the query string. Query
1036        // parameters frequently carry tokens/secrets (e.g. an attacker
1037        // probing `GET /healthz?secret=exfil`) and we MUST NOT plant
1038        // them in span attributes that flow to log sinks. Handlers that
1039        // legitimately need the query string read it from
1040        // `req.uri().query()` themselves under their own scrubbing.
1041        //
1042        // Both `path` and `method` flow through bounded sanitisers
1043        // (`sanitize_path` / `normalize_method`) before reaching the
1044        // span so that path-traversal probes (`/functions/../etc/passwd`)
1045        // and CRLF-injection payloads (`/foo%0d%0aevil-header:%20yes`)
1046        // can neither forge log lines nor smuggle terminal-escape
1047        // sequences into operator dashboards. `traceparent` has its
1048        // own dedicated sanitiser above.
1049        let sanitised_path = sanitize_path(req.uri().path());
1050        let normalised_method = normalize_method(req.method().as_str());
1051        let span = tracing::info_span!(
1052            "http.request",
1053            method = %normalised_method,
1054            path = %sanitised_path,
1055            version = ?req.version(),
1056            traceparent = %sanitised_tp,
1057        );
1058
1059        // Extract the parent `opentelemetry::Context` from the incoming
1060        // headers via the globally-installed `TextMapPropagator`. The
1061        // `set_parent` call hooks the freshly-created tracing span into
1062        // the upstream W3C trace, so subsequent `#[instrument]` spans on
1063        // tenant lookup, executor spawn, snapshot restore, and dispatch
1064        // all share the same `trace_id`.
1065        let parent_cx = crate::trace_propagation::extract_parent_context(req.headers());
1066        span.set_parent(parent_cx);
1067
1068        span
1069    })
1070}
1071
1072/// Maximum byte length a `traceparent` header value is allowed to
1073/// occupy in a span attribute. The W3C Trace Context spec caps a
1074/// well-formed value at 55 bytes; 64 gives a tiny margin for future
1075/// versioned suffixes while still bounding the per-span log footprint
1076/// to a constant. Anything longer is truncated.
1077const TRACEPARENT_MAX_BYTES: usize = 64;
1078
1079/// Sentinel emitted in span attributes when a `traceparent` header
1080/// contains characters that would corrupt log output (CR, LF, NUL).
1081/// Choosing a fixed token rather than the original (filtered) value
1082/// keeps grep/audit signatures stable across hostile inputs.
1083const TRACEPARENT_INVALID_SENTINEL: &str = "<invalid>";
1084
1085/// Render an inbound `traceparent` header value as a bounded, log-safe
1086/// string for use as a tracing span attribute.
1087///
1088/// The header is attacker-controlled, so we apply three defences:
1089///
1090/// 1. **Reject control characters.** Any CR, LF, or NUL byte indicates
1091///    an attempt to inject a fake log line (CRLF) or terminate a C
1092///    string in a downstream consumer (NUL). We collapse the entire
1093///    value to the [`TRACEPARENT_INVALID_SENTINEL`] in that case rather
1094///    than try to filter — partial sanitisation is exactly the kind of
1095///    surface that breeds bypasses.
1096/// 2. **Clamp length to 64 bytes.** The W3C grammar caps a valid value
1097///    at 55 bytes; anything longer is either malformed or hostile
1098///    padding. Truncation happens at a UTF-8 char boundary so we never
1099///    return invalid UTF-8 to the tracing layer.
1100/// 3. **Strip non-printable bytes** (anything outside `0x20..=0x7E`).
1101///    Printable ASCII is the entire grammar the spec allows, and
1102///    keeping span attributes plain ASCII prevents terminal-escape
1103///    smuggling through log viewers.
1104///
1105/// When the input is already a clean ASCII-printable string short
1106/// enough to fit, we return `Cow::Borrowed` to avoid the allocation.
1107fn sanitise_traceparent(raw: &str) -> std::borrow::Cow<'_, str> {
1108    use std::borrow::Cow;
1109
1110    // Defence 1: reject the whole value when control chars appear.
1111    // CRLF is the classic log-injection vector; NUL the classic C
1112    // boundary trick. Bail before doing any other processing so the
1113    // sentinel is stable.
1114    if raw.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
1115        return Cow::Owned(TRACEPARENT_INVALID_SENTINEL.to_string());
1116    }
1117
1118    // Fast path: already short, already printable ASCII -> borrow.
1119    let is_clean =
1120        raw.len() <= TRACEPARENT_MAX_BYTES && raw.bytes().all(|b| (0x20..=0x7E).contains(&b));
1121    if is_clean {
1122        return Cow::Borrowed(raw);
1123    }
1124
1125    // Slow path: build a filtered, clamped owned copy. Walk by char so
1126    // truncation never lands mid-codepoint, and skip anything outside
1127    // printable ASCII.
1128    let mut out = String::with_capacity(raw.len().min(TRACEPARENT_MAX_BYTES));
1129    for ch in raw.chars() {
1130        let b = ch as u32;
1131        if !(0x20..=0x7E).contains(&b) {
1132            continue;
1133        }
1134        let ch_len = ch.len_utf8();
1135        if out.len() + ch_len > TRACEPARENT_MAX_BYTES {
1136            break;
1137        }
1138        out.push(ch);
1139    }
1140    Cow::Owned(out)
1141}
1142
1143/// Maximum byte length that a request path is allowed to occupy in a
1144/// tracing span attribute (see [`sanitize_path`]). Anything longer is
1145/// truncated with a `…` suffix to keep operator log lines bounded and
1146/// to deny a hostile client a cheap way to balloon every log record
1147/// that touches their request.
1148///
1149/// 256 bytes comfortably accommodates every route template the API
1150/// exposes today (the longest, `/functions/{id}/invoke-async`, is far
1151/// under that even after path-parameter substitution) while still
1152/// bounding the per-span attribute footprint to a constant. Bump only
1153/// after auditing the dashboard layouts in `docs/OBSERVABILITY.md` —
1154/// a wider value widens the log-line budget linearly.
1155pub const MAX_PATH_LEN: usize = 256;
1156
1157/// Sentinel returned by [`normalize_method`] for any HTTP method that
1158/// does not match the `[A-Z]{1,16}` shape. Keeping a fixed token rather
1159/// than the original (filtered) value preserves grep/audit signatures
1160/// across hostile inputs — every malformed method bucket maps to the
1161/// same string.
1162const METHOD_OTHER_SENTINEL: &str = "OTHER";
1163
1164/// Upper bound on the byte length of an accepted HTTP method name.
1165/// Standard methods are at most 7 bytes (`OPTIONS`); 16 leaves room
1166/// for legitimate WebDAV-style extensions (`MKCALENDAR`, `PROPPATCH`)
1167/// while rejecting padding-style abuse.
1168const METHOD_MAX_LEN: usize = 16;
1169
1170/// Render a request path as a bounded, log-safe string for use as a
1171/// tracing span attribute.
1172///
1173/// The path is attacker-controlled (it flows out of the request URI
1174/// after axum's routing layer), so we apply three defences:
1175///
1176/// 1. **Truncate to [`MAX_PATH_LEN`] bytes** with a `…` ellipsis
1177///    suffix when the input is longer. Truncation lands on a UTF-8
1178///    char boundary so we never emit invalid UTF-8 to the tracing
1179///    layer. The ellipsis is one Unicode code point (`U+2026`, three
1180///    UTF-8 bytes) so the returned `Cow::Owned` is at most
1181///    `MAX_PATH_LEN + 3` bytes — the test in
1182///    `tests/trace_sanitization_test.rs` asserts `MAX_PATH_LEN + 4`
1183///    to give a one-byte margin against future ellipsis tweaks.
1184/// 2. **Replace non-printable / non-ASCII bytes with `?`.** Anything
1185///    outside `0x20..=0x7E` (printable ASCII) is collapsed to a
1186///    single `?` so terminal-escape sequences cannot smuggle out of
1187///    a log viewer and multi-byte UTF-8 sequences (e.g. `é` →
1188///    `0xC3 0xA9`) cannot be reconstructed downstream.
1189/// 3. **Strip CR / LF / NUL specifically.** The printable-byte filter
1190///    above already catches these, but we apply the explicit strip as
1191///    defence in depth: a future relaxation of the printable filter
1192///    must NOT re-open the CRLF-injection channel (forging fake JSON
1193///    log lines) or the NUL channel (terminating C-string consumers).
1194///
1195/// When the input is already a clean ASCII-printable string short
1196/// enough to fit, we return `Cow::Borrowed` to avoid the allocation.
1197pub fn sanitize_path(raw: &str) -> std::borrow::Cow<'_, str> {
1198    use std::borrow::Cow;
1199
1200    // Fast path: already short, already printable ASCII, no
1201    // CR/LF/NUL -> borrow. Walking once via `bytes().all` lets the
1202    // compiler fuse the checks; we only allocate when something
1203    // actually needs rewriting.
1204    let is_clean = raw.len() <= MAX_PATH_LEN
1205        && raw
1206            .bytes()
1207            .all(|b| (0x20..=0x7E).contains(&b) && b != b'\r' && b != b'\n' && b != 0);
1208    if is_clean {
1209        return Cow::Borrowed(raw);
1210    }
1211
1212    // Slow path: build a filtered, clamped owned copy. Walk by char
1213    // so truncation never lands mid-codepoint; replace anything
1214    // outside printable ASCII with a literal `?` rather than dropping
1215    // it, so a path like `/café` round-trips to `/caf??` (two bytes
1216    // for `é` → two `?` substitutions) and the resulting attribute
1217    // length is observable to operators rather than silently
1218    // shrinking.
1219    //
1220    // We reserve `MAX_PATH_LEN` up front; the ellipsis (if any) is
1221    // appended afterwards so the truncation check operates on the
1222    // pre-ellipsis byte budget.
1223    let mut out = String::with_capacity(raw.len().min(MAX_PATH_LEN));
1224    let mut truncated = false;
1225    for ch in raw.chars() {
1226        // Defence 3: strip CR/LF/NUL explicitly even though the
1227        // printable filter below would catch them. A future relaxation
1228        // must not re-open the injection channel.
1229        if ch == '\r' || ch == '\n' || ch == '\0' {
1230            out.push('?');
1231            if out.len() >= MAX_PATH_LEN {
1232                truncated = true;
1233                break;
1234            }
1235            continue;
1236        }
1237        let b = ch as u32;
1238        let replacement = if (0x20..=0x7E).contains(&b) {
1239            // Printable ASCII: keep the character as-is (it occupies
1240            // one byte in UTF-8 by definition).
1241            ch
1242        } else {
1243            // Non-printable or non-ASCII: collapse to `?`. This
1244            // includes every byte of a multi-byte UTF-8 sequence
1245            // because we iterate by `char`, not by byte, so each
1246            // non-ASCII code point contributes exactly one `?` to
1247            // the output regardless of how many bytes it occupies
1248            // in the input.
1249            '?'
1250        };
1251        let ch_len = replacement.len_utf8();
1252        if out.len() + ch_len > MAX_PATH_LEN {
1253            truncated = true;
1254            break;
1255        }
1256        out.push(replacement);
1257    }
1258    if truncated {
1259        // Append a single-code-point ellipsis (`…`, U+2026, 3 bytes
1260        // in UTF-8) so the truncated value is visually distinguishable
1261        // from a non-truncated one. Total length stays bounded at
1262        // `MAX_PATH_LEN + 3` bytes — see the doc comment above.
1263        out.push('…');
1264    }
1265    Cow::Owned(out)
1266}
1267
1268/// Normalise an HTTP method name for use as a tracing span attribute.
1269///
1270/// Returns the input borrowed when it matches `[A-Z]{1,16}` exactly
1271/// (every standard method — `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,
1272/// `HEAD`, `OPTIONS`, `TRACE`, `CONNECT` — passes through verbatim).
1273/// Anything else — lowercase (`get`), mixed case (`Get`), control
1274/// characters, non-ASCII, oversized padding — collapses to the
1275/// `METHOD_OTHER_SENTINEL` (`"OTHER"`).
1276///
1277/// HTTP method names are far less risky than paths (hyper rejects
1278/// most malformed values before they reach this layer) but we still
1279/// normalise so that:
1280///
1281/// * dashboard label cardinality stays bounded (no per-request method
1282///   variant exploding the metrics index),
1283/// * a custom client cannot smuggle non-standard bytes into the
1284///   `method` span field that the path sanitiser would have rejected.
1285pub fn normalize_method(raw: &str) -> std::borrow::Cow<'_, str> {
1286    use std::borrow::Cow;
1287
1288    let bytes = raw.as_bytes();
1289    let valid = !bytes.is_empty()
1290        && bytes.len() <= METHOD_MAX_LEN
1291        && bytes.iter().all(|b| b.is_ascii_uppercase());
1292    if valid {
1293        Cow::Borrowed(raw)
1294    } else {
1295        Cow::Owned(METHOD_OTHER_SENTINEL.to_string())
1296    }
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302    use axum::http::HeaderValue;
1303
1304    #[test]
1305    fn timeout_layer_constructs() {
1306        let _ = timeout_layer(Duration::from_millis(1));
1307        let _ = timeout_layer(DEFAULT_REQUEST_TIMEOUT);
1308    }
1309
1310    #[test]
1311    fn trace_layer_constructs() {
1312        let _ = trace_layer();
1313    }
1314
1315    #[test]
1316    fn concurrency_limit_layer_constructs() {
1317        let _ = concurrency_limit_layer(1);
1318        let _ = concurrency_limit_layer(DEFAULT_CONCURRENCY_LIMIT);
1319    }
1320
1321    #[test]
1322    fn body_limit_layer_constructs() {
1323        let _ = body_limit_layer(MAX_REQUEST_BODY_BYTES);
1324    }
1325
1326    #[test]
1327    fn cors_config_default_is_empty_allowlist() {
1328        let cfg = CorsConfig::default();
1329        assert!(cfg.allowed_origins.is_empty());
1330    }
1331
1332    #[test]
1333    fn cors_config_from_origins_round_trips() {
1334        let cfg = CorsConfig::from_origins(["https://app.example.com"]);
1335        assert_eq!(cfg.allowed_origins, vec!["https://app.example.com"]);
1336    }
1337
1338    #[test]
1339    fn cors_layer_constructs_empty_allowlist() {
1340        // The empty-allowlist branch is the safe default: it must produce
1341        // a layer that does not set Access-Control-Allow-Origin. We only
1342        // exercise construction here; the wire-level rejection check sits
1343        // in the integration test suite where a full router is in scope.
1344        let _ = cors_layer(&CorsConfig::default());
1345    }
1346
1347    #[test]
1348    fn cors_layer_constructs_with_origins() {
1349        let _ = cors_layer(&CorsConfig::from_origins([
1350            "https://app.example.com",
1351            "https://admin.example.com",
1352        ]));
1353    }
1354
1355    #[test]
1356    fn trace_layer_with_propagation_constructs() {
1357        let _ = trace_layer_with_propagation();
1358    }
1359
1360    #[test]
1361    fn sanitise_traceparent_passes_through_well_formed_value() {
1362        // A literal example from the W3C Trace Context spec. Already
1363        // printable ASCII, already under the 64-byte cap, so the
1364        // helper must return the borrowed pointer (no allocation,
1365        // exact byte equality).
1366        let sample = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
1367        let out = sanitise_traceparent(sample);
1368        assert_eq!(out.as_ref(), sample);
1369        assert!(
1370            matches!(out, std::borrow::Cow::Borrowed(_)),
1371            "well-formed value must be borrowed, not allocated",
1372        );
1373    }
1374
1375    #[test]
1376    fn sanitise_traceparent_rejects_crlf_injection() {
1377        // CRLF in a header value is the classic log-injection vector;
1378        // a hostile client could plant a forged "log line" into our
1379        // structured output. The helper must collapse the whole value
1380        // to a stable sentinel, NOT a partially-filtered string.
1381        let attack = "\r\nSET-COOKIE: x=y\r\n";
1382        let out = sanitise_traceparent(attack);
1383        assert_eq!(out.as_ref(), "<invalid>");
1384    }
1385
1386    #[test]
1387    fn sanitise_traceparent_rejects_embedded_nul() {
1388        // NUL bytes truncate C strings in downstream consumers; treat
1389        // them as hostile and emit the sentinel.
1390        let attack = "00-aaaa\0bbbb";
1391        let out = sanitise_traceparent(attack);
1392        assert_eq!(out.as_ref(), "<invalid>");
1393    }
1394
1395    #[test]
1396    fn sanitise_traceparent_truncates_oversized_input_to_64_bytes() {
1397        // A 100-byte all-'a' string is printable ASCII but exceeds
1398        // the 64-byte cap. The helper must clamp to exactly 64
1399        // bytes — the W3C maximum is 55, so 64 already accommodates
1400        // every legitimate value with room to spare.
1401        let input = "a".repeat(100);
1402        let out = sanitise_traceparent(&input);
1403        assert_eq!(out.len(), 64);
1404        assert!(out.chars().all(|c| c == 'a'));
1405    }
1406
1407    #[test]
1408    fn sanitise_traceparent_filters_non_printable_bytes() {
1409        // ESC (0x1B) is outside printable ASCII and could be used to
1410        // smuggle terminal escape sequences through log viewers.
1411        // The helper must strip the byte but keep the surrounding
1412        // printable context.
1413        let attack = "00-aaaa\x1Bbbbb-cc";
1414        let out = sanitise_traceparent(attack);
1415        assert!(!out.contains('\x1B'));
1416        assert!(out.contains("aaaa"));
1417        assert!(out.contains("bbbb"));
1418    }
1419
1420    #[test]
1421    fn auth_config_dev_mode_when_empty() {
1422        let cfg = AuthConfig::from_tokens(Vec::<String>::new());
1423        assert!(cfg.is_dev_mode());
1424        // dev-mode `accepts` is irrelevant, but should return `false` —
1425        // the dev gate runs in `bearer_auth`, not here.
1426        assert!(!cfg.accepts("anything"));
1427    }
1428
1429    // ---- M4: dev-mode opt-in gate -------------------------------------
1430
1431    /// Programmatic constructors are an explicit in-process opt-in, so they
1432    /// keep the historical pass-through behaviour (`dev_mode_allowed == true`).
1433    /// This preserves every test/embedder that builds a dev `AuthConfig`
1434    /// directly without touching the process environment.
1435    #[test]
1436    fn auth_config_programmatic_constructors_allow_dev_mode() {
1437        assert!(AuthConfig::default().dev_mode_allowed);
1438        assert!(AuthConfig::from_tokens(Vec::<String>::new()).dev_mode_allowed);
1439        assert!(AuthConfig::from_scopes(Vec::<(String, TokenScope)>::new()).dev_mode_allowed);
1440    }
1441
1442    /// `from_env` with an empty allowlist and no opt-in must produce a config
1443    /// that forbids dev mode (M4 fail-closed). The env mutation is scoped by
1444    /// `temp_env` so it cannot race other tests.
1445    #[test]
1446    fn auth_config_from_env_empty_without_opt_in_forbids_dev_mode() {
1447        temp_env::with_vars(
1448            [
1449                (ENV_API_TOKENS, None::<&str>),
1450                (ENV_ALLOW_DEV_MODE, None::<&str>),
1451            ],
1452            || {
1453                let cfg = AuthConfig::from_env();
1454                assert!(cfg.is_dev_mode());
1455                assert!(
1456                    !cfg.dev_mode_allowed,
1457                    "empty allowlist + no opt-in must fail closed",
1458                );
1459            },
1460        );
1461    }
1462
1463    /// The opt-in honours both `1` and (case-insensitive) `true`.
1464    #[test]
1465    fn auth_config_from_env_opt_in_values_enable_dev_mode() {
1466        for val in ["1", "true", "TRUE", " true "] {
1467            temp_env::with_vars(
1468                [
1469                    (ENV_API_TOKENS, None::<&str>),
1470                    (ENV_ALLOW_DEV_MODE, Some(val)),
1471                ],
1472                || {
1473                    let cfg = AuthConfig::from_env();
1474                    assert!(cfg.is_dev_mode());
1475                    assert!(
1476                        cfg.dev_mode_allowed,
1477                        "ENV_ALLOW_DEV_MODE={val:?} must enable dev mode",
1478                    );
1479                },
1480            );
1481        }
1482    }
1483
1484    /// A non-truthy opt-in value leaves dev mode disabled.
1485    #[test]
1486    fn auth_config_from_env_non_truthy_opt_in_forbids_dev_mode() {
1487        for val in ["0", "false", "yes", ""] {
1488            temp_env::with_vars(
1489                [
1490                    (ENV_API_TOKENS, None::<&str>),
1491                    (ENV_ALLOW_DEV_MODE, Some(val)),
1492                ],
1493                || {
1494                    assert!(
1495                        !AuthConfig::from_env().dev_mode_allowed,
1496                        "ENV_ALLOW_DEV_MODE={val:?} must NOT enable dev mode",
1497                    );
1498                },
1499            );
1500        }
1501    }
1502
1503    /// When tokens ARE configured the opt-in is irrelevant — the config is
1504    /// not in dev mode and `bearer_auth` enforces the allowlist as before.
1505    #[test]
1506    fn auth_config_from_env_with_tokens_is_not_dev_mode() {
1507        temp_env::with_vars(
1508            [
1509                (ENV_API_TOKENS, Some("secret:tenant=*")),
1510                (ENV_ALLOW_DEV_MODE, None::<&str>),
1511            ],
1512            || {
1513                let cfg = AuthConfig::from_env();
1514                assert!(!cfg.is_dev_mode());
1515                assert!(cfg.accepts("secret"));
1516            },
1517        );
1518    }
1519
1520    /// End-to-end: a fail-closed dev config (`dev_mode_allowed == false`) must
1521    /// reject an unauthenticated request through the real `bearer_auth`
1522    /// middleware with `401 Unauthorized`, rather than passing it through.
1523    #[tokio::test]
1524    async fn bearer_auth_fails_closed_when_dev_mode_not_allowed() {
1525        use axum::routing::get;
1526        use tower::ServiceExt as _;
1527
1528        let cfg = temp_env::with_vars(
1529            [
1530                (ENV_API_TOKENS, None::<&str>),
1531                (ENV_ALLOW_DEV_MODE, None::<&str>),
1532            ],
1533            AuthConfig::from_env,
1534        );
1535        assert!(!cfg.dev_mode_allowed);
1536
1537        let router = axum::Router::new()
1538            .route("/x", get(|| async { "ok" }))
1539            .layer(axum::middleware::from_fn(bearer_auth))
1540            .layer(axum::Extension(cfg));
1541
1542        let resp = router
1543            .oneshot(
1544                Request::builder()
1545                    .uri("/x")
1546                    .body(axum::body::Body::empty())
1547                    .unwrap(),
1548            )
1549            .await
1550            .unwrap();
1551        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1552    }
1553
1554    /// End-to-end counterpart: with dev mode explicitly allowed the same
1555    /// unauthenticated request passes through to the handler (`200 OK`).
1556    #[tokio::test]
1557    async fn bearer_auth_passes_through_when_dev_mode_allowed() {
1558        use axum::routing::get;
1559        use tower::ServiceExt as _;
1560
1561        // `default()` is an explicit in-process opt-in (dev_mode_allowed).
1562        let router = axum::Router::new()
1563            .route("/x", get(|| async { "ok" }))
1564            .layer(axum::middleware::from_fn(bearer_auth))
1565            .layer(axum::Extension(AuthConfig::default()));
1566
1567        let resp = router
1568            .oneshot(
1569                Request::builder()
1570                    .uri("/x")
1571                    .body(axum::body::Body::empty())
1572                    .unwrap(),
1573            )
1574            .await
1575            .unwrap();
1576        assert_eq!(resp.status(), StatusCode::OK);
1577    }
1578
1579    #[test]
1580    fn auth_config_accepts_matching_token() {
1581        let cfg = AuthConfig::from_tokens(["foo", "bar"]);
1582        assert!(!cfg.is_dev_mode());
1583        assert!(cfg.accepts("foo"));
1584        assert!(cfg.accepts("bar"));
1585        assert!(!cfg.accepts("baz"));
1586    }
1587
1588    #[test]
1589    fn auth_config_from_tokens_defaults_to_wildcard_scope() {
1590        let cfg = AuthConfig::from_tokens(["foo"]);
1591        let scope = cfg.scope_for("foo").expect("scope present");
1592        assert!(
1593            scope.tenants.is_all(),
1594            "from_tokens must default to wildcard"
1595        );
1596    }
1597
1598    #[test]
1599    fn auth_config_from_scopes_round_trips() {
1600        let cfg = AuthConfig::from_scopes([
1601            (
1602                "foo",
1603                crate::token_scope::TokenScope::from_tenants([TenantId(1)]),
1604            ),
1605            ("bar", crate::token_scope::TokenScope::all()),
1606        ]);
1607        assert!(cfg.accepts("foo"));
1608        assert!(cfg.accepts("bar"));
1609        let foo = cfg.scope_for("foo").expect("foo");
1610        assert!(foo.allows(TenantId(1)));
1611        assert!(!foo.allows(TenantId(2)));
1612        let bar = cfg.scope_for("bar").expect("bar");
1613        assert!(bar.allows(TenantId(99)));
1614    }
1615
1616    #[test]
1617    fn extract_tenant_default_zero_when_optional() {
1618        let headers = HeaderMap::new();
1619        let cfg = TenantConfig {
1620            require_header: false,
1621        };
1622        let tid = extract_tenant(&headers, cfg).expect("default to TenantId(0)");
1623        assert_eq!(tid, TenantId(0));
1624    }
1625
1626    #[test]
1627    fn extract_tenant_errors_when_required_and_missing() {
1628        let headers = HeaderMap::new();
1629        let cfg = TenantConfig {
1630            require_header: true,
1631        };
1632        let err = extract_tenant(&headers, cfg).expect_err("required header missing");
1633        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1634    }
1635
1636    #[test]
1637    fn extract_tenant_parses_header_value() {
1638        let mut headers = HeaderMap::new();
1639        headers.insert(HEADER_TENANT, HeaderValue::from_static("7"));
1640        let tid = extract_tenant(
1641            &headers,
1642            TenantConfig {
1643                require_header: false,
1644            },
1645        )
1646        .expect("parses");
1647        assert_eq!(tid, TenantId(7));
1648    }
1649
1650    #[test]
1651    fn parse_bearer_credentials_accepts_canonical_scheme() {
1652        assert_eq!(parse_bearer_credentials("Bearer abc"), Some("abc"));
1653    }
1654
1655    #[test]
1656    fn parse_bearer_credentials_is_case_insensitive() {
1657        // Load balancers (e.g. Envoy / nginx lowercase plugins) may have
1658        // normalised the scheme; RFC 6750 §2.1 says scheme matching is
1659        // case-insensitive.
1660        assert_eq!(parse_bearer_credentials("bearer abc"), Some("abc"));
1661        assert_eq!(parse_bearer_credentials("BEARER abc"), Some("abc"));
1662        assert_eq!(parse_bearer_credentials("BeArEr abc"), Some("abc"));
1663    }
1664
1665    #[test]
1666    fn parse_bearer_credentials_accepts_tab_separator() {
1667        assert_eq!(parse_bearer_credentials("Bearer\tabc"), Some("abc"));
1668    }
1669
1670    #[test]
1671    fn parse_bearer_credentials_trims_surrounding_whitespace() {
1672        assert_eq!(parse_bearer_credentials("Bearer   abc"), Some("abc"));
1673        assert_eq!(parse_bearer_credentials("Bearer abc  "), Some("abc"));
1674        assert_eq!(parse_bearer_credentials("Bearer \t abc \t "), Some("abc"));
1675    }
1676
1677    #[test]
1678    fn parse_bearer_credentials_rejects_other_schemes() {
1679        assert_eq!(parse_bearer_credentials("Basic ZGVhZGJlZWY="), None);
1680        assert_eq!(parse_bearer_credentials("Token abc"), None);
1681    }
1682
1683    #[test]
1684    fn parse_bearer_credentials_returns_none_for_no_whitespace() {
1685        // No separator at all => not a parseable Authorization value.
1686        assert_eq!(parse_bearer_credentials("Bearer"), None);
1687        assert_eq!(parse_bearer_credentials(""), None);
1688    }
1689
1690    #[test]
1691    fn parse_bearer_credentials_empty_token_is_some_empty() {
1692        // Caller (`bearer_auth`) is responsible for the empty-token check;
1693        // we surface the empty string so it can reject with the same shape
1694        // as a missing header.
1695        assert_eq!(parse_bearer_credentials("Bearer   "), Some(""));
1696    }
1697
1698    #[test]
1699    fn extract_tenant_rejects_garbage_header() {
1700        let mut headers = HeaderMap::new();
1701        headers.insert(HEADER_TENANT, HeaderValue::from_static("not-a-number"));
1702        let err = extract_tenant(
1703            &headers,
1704            TenantConfig {
1705                require_header: false,
1706            },
1707        )
1708        .expect_err("rejects garbage");
1709        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1710    }
1711}