Skip to main content

omni_dev/browser/
auth.rs

1//! Authentication and request-guard primitives for the browser bridge.
2//!
3//! The trust boundary, stated once: **a request is trusted only if it presents
4//! the session token AND is not a cross-origin browser request; everything else
5//! is denied.** A localhost bind is necessary but not sufficient — it stops
6//! off-host access but not other local users/processes, nor web pages the
7//! operator visits. See [ADR-0036](../../docs/adrs/adr-0036.md).
8//!
9//! The functions here are deliberately small and operate on borrowed primitives
10//! rather than framework types so the security checks can be unit-tested in
11//! isolation from axum / tungstenite.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16use anyhow::{bail, Context, Result};
17use base64::Engine;
18use rand::Rng;
19
20use crate::utils::env::{EnvSource, SystemEnv};
21
22/// Environment variable an operator may use to pin the session token instead of
23/// letting the bridge generate one. Never read from argv (`ps`/`/proc` expose
24/// it).
25pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
26
27/// Custom header every control-plane request must carry. A custom header forces
28/// a CORS preflight that the server refuses, blocking simple-request CSRF.
29pub const BRIDGE_HEADER: &str = "x-omni-bridge";
30
31/// Required value of [`BRIDGE_HEADER`].
32pub const BRIDGE_HEADER_VALUE: &str = "1";
33
34/// Optional header selecting which connected tab a control-plane request targets.
35///
36/// The value is a connection id, or an `Origin` that uniquely matches one tab. It
37/// takes precedence over a `target` body field, and is stripped before forwarding.
38pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
39
40/// Number of random bytes behind a generated token (URL-safe base64, no pad).
41const TOKEN_BYTES: usize = 32;
42
43/// Generates a fresh random session token (256 bits, URL-safe base64).
44#[must_use]
45pub fn generate_token() -> String {
46    let mut bytes = [0u8; TOKEN_BYTES];
47    rand::rng().fill_bytes(&mut bytes);
48    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
49}
50
51/// Resolves the session token, in priority order:
52///
53/// 1. `--token-file` — read its trimmed contents. On Unix the file must be
54///    `0600` (owner-only) or resolution fails closed.
55/// 2. `OMNI_BRIDGE_TOKEN` — read from the environment.
56/// 3. Otherwise a fresh token is generated.
57///
58/// The token is **never** accepted from argv.
59pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
60    resolve_token_with(&SystemEnv, token_file)
61}
62
63/// [`resolve_token`] over an injected [`EnvSource`], so the `OMNI_BRIDGE_TOKEN`
64/// branch is tested without mutating the process environment (issue #1030).
65pub(crate) fn resolve_token_with(
66    env: &impl EnvSource,
67    token_file: Option<&Path>,
68) -> Result<String> {
69    if let Some(path) = token_file {
70        return read_token_file(path);
71    }
72    if let Some(value) = env.var(TOKEN_ENV) {
73        let trimmed = value.trim();
74        if !trimmed.is_empty() {
75            return Ok(trimmed.to_string());
76        }
77    }
78    Ok(generate_token())
79}
80
81/// Resolves an *existing* session token for a client.
82///
83/// Used by the `request` subcommand: `--token-file` then `OMNI_BRIDGE_TOKEN`.
84/// Unlike [`resolve_token`], it never generates one — a client must use the
85/// token the running bridge printed.
86pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
87    resolve_existing_token_with(&SystemEnv, token_file)
88}
89
90/// [`resolve_existing_token`] over an injected [`EnvSource`]; never generates a
91/// token. Tests pass a `MapEnv` rather than mutating the process environment.
92pub(crate) fn resolve_existing_token_with(
93    env: &impl EnvSource,
94    token_file: Option<&Path>,
95) -> Result<String> {
96    if let Some(path) = token_file {
97        return read_token_file(path);
98    }
99    match env.var(TOKEN_ENV) {
100        Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
101        _ => bail!(
102            "No session token found. Set {TOKEN_ENV} or pass --token-file with the token the \
103             running bridge printed."
104        ),
105    }
106}
107
108fn read_token_file(path: &Path) -> Result<String> {
109    #[cfg(unix)]
110    {
111        use std::os::unix::fs::PermissionsExt;
112        let meta = std::fs::metadata(path)
113            .with_context(|| format!("Failed to stat token file {}", path.display()))?;
114        let mode = meta.permissions().mode() & 0o777;
115        if mode & 0o077 != 0 {
116            bail!(
117                "Token file {} must be 0600 (owner-only); found {:o}",
118                path.display(),
119                mode
120            );
121        }
122    }
123    let contents = std::fs::read_to_string(path)
124        .with_context(|| format!("Failed to read token file {}", path.display()))?;
125    let trimmed = contents.trim();
126    if trimmed.is_empty() {
127        bail!("Token file {} is empty", path.display());
128    }
129    Ok(trimmed.to_string())
130}
131
132/// Constant-time string comparison, to avoid leaking the token via timing.
133#[must_use]
134pub fn constant_time_eq(a: &str, b: &str) -> bool {
135    let (a, b) = (a.as_bytes(), b.as_bytes());
136    if a.len() != b.len() {
137        return false;
138    }
139    let mut diff = 0u8;
140    for (x, y) in a.iter().zip(b.iter()) {
141        diff |= x ^ y;
142    }
143    diff == 0
144}
145
146/// Checks an `Authorization` header value against the expected token.
147///
148/// Accepts only `Bearer <token>` with a constant-time token comparison.
149#[must_use]
150pub fn bearer_matches(authorization: Option<&str>, token: &str) -> bool {
151    let Some(value) = authorization else {
152        return false;
153    };
154    let Some(presented) = value.strip_prefix("Bearer ") else {
155        return false;
156    };
157    constant_time_eq(presented.trim(), token)
158}
159
160/// Whether the request carries the mandatory `X-Omni-Bridge: 1` header.
161#[must_use]
162pub fn has_bridge_header(value: Option<&str>) -> bool {
163    value.is_some_and(|v| v.trim() == BRIDGE_HEADER_VALUE)
164}
165
166/// Whether `host` is an allowed loopback authority for `control_port`.
167///
168/// Blocks DNS rebinding: only the exact `localhost:<port>` / `127.0.0.1:<port>`
169/// (and the IPv6 loopback) authorities are accepted.
170#[must_use]
171pub fn host_allowed(host: &str, control_port: u16) -> bool {
172    let allowed = [
173        format!("localhost:{control_port}"),
174        format!("127.0.0.1:{control_port}"),
175        format!("[::1]:{control_port}"),
176    ];
177    allowed.iter().any(|a| a == host)
178}
179
180/// Whether a request looks browser-originated and must therefore be denied.
181///
182/// A legitimate CLI client sends neither an `Origin` header nor
183/// `Sec-Fetch-Site: cross-site`/`same-site`. Any such header marks a request a
184/// web page made, which the control plane refuses.
185#[must_use]
186pub fn is_browser_originated(origin: Option<&str>, sec_fetch_site: Option<&str>) -> bool {
187    if origin.is_some() {
188        return true;
189    }
190    matches!(
191        sec_fetch_site.map(str::trim),
192        Some("cross-site" | "same-site" | "same-origin")
193    )
194}
195
196/// Rejects header names/values that could smuggle a second header or request
197/// line via CR/LF (or other control characters).
198#[must_use]
199pub fn header_is_safe(name: &str, value: &str) -> bool {
200    let bad = |s: &str| s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0);
201    !name.is_empty() && !bad(name) && !bad(value)
202}
203
204/// Normalises a decoded request path, rejecting traversal and control
205/// characters. Run **before** the `/__bridge/` routing/auth split so a
206/// percent-encoded segment cannot bypass the prefix check.
207///
208/// Returns the percent-decoded path, or `None` if it is unsafe.
209#[must_use]
210pub fn normalize_request_path(raw: &str) -> Option<String> {
211    let decoded = percent_decode(raw)?;
212    if decoded.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
213        return None;
214    }
215    // Reject any `..` path segment (traversal).
216    if decoded
217        .split('/')
218        .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
219    {
220        return None;
221    }
222    Some(decoded)
223}
224
225/// Minimal percent-decoder for request paths. Returns `None` on malformed
226/// escapes or non-UTF-8 results.
227fn percent_decode(raw: &str) -> Option<String> {
228    let bytes = raw.as_bytes();
229    let mut out = Vec::with_capacity(bytes.len());
230    let mut i = 0;
231    while i < bytes.len() {
232        match bytes[i] {
233            b'%' => {
234                let hi = bytes.get(i + 1).copied().and_then(hex_val)?;
235                let lo = bytes.get(i + 2).copied().and_then(hex_val)?;
236                out.push(hi << 4 | lo);
237                i += 3;
238            }
239            b => {
240                out.push(b);
241                i += 1;
242            }
243        }
244    }
245    String::from_utf8(out).ok()
246}
247
248fn hex_val(b: u8) -> Option<u8> {
249    match b {
250        b'0'..=b'9' => Some(b - b'0'),
251        b'a'..=b'f' => Some(b - b'a' + 10),
252        b'A'..=b'F' => Some(b - b'A' + 10),
253        _ => None,
254    }
255}
256
257/// Reason an outbound URL was rejected by [`validate_outbound_url`].
258#[derive(Debug, PartialEq, Eq)]
259pub enum ScopeError {
260    /// The URL is absolute/cross-origin and no `--allow-origin` permits it.
261    CrossOriginDenied,
262    /// The URL could not be parsed or is otherwise malformed.
263    Malformed,
264}
265
266/// Enforces the default-closed outbound scope.
267///
268/// Relative URLs (page-origin) are always allowed. Absolute or
269/// protocol-relative URLs are rejected unless their origin matches one of the
270/// `allowed` origins. An empty `allowed` slice permits relative URLs only.
271///
272/// The caller resolves `allowed`: the per-request override (a single origin) or
273/// the per-origin allowlist entry for the tab a request is routed to (see
274/// [`OriginAllowlist::outbound_for`]).
275pub fn validate_outbound_url(url: &str, allowed: &[&str]) -> Result<(), ScopeError> {
276    // Protocol-relative (`//host/...`) is cross-origin.
277    let is_relative = url.starts_with('/') && !url.starts_with("//");
278    if is_relative {
279        if url.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
280            return Err(ScopeError::Malformed);
281        }
282        return Ok(());
283    }
284
285    // No grant covers an absolute URL — reject before parsing (a malformed
286    // absolute URL with no allowance is still simply cross-origin-denied).
287    if allowed.is_empty() {
288        return Err(ScopeError::CrossOriginDenied);
289    }
290    let target = url::Url::parse(url).map_err(|_| ScopeError::Malformed)?;
291    let permitted = allowed
292        .iter()
293        .filter_map(|a| url::Url::parse(a).ok())
294        .any(|allow| origins_match(&target, &allow));
295    if permitted {
296        Ok(())
297    } else {
298        Err(ScopeError::CrossOriginDenied)
299    }
300}
301
302fn origins_match(a: &url::Url, b: &url::Url) -> bool {
303    a.scheme() == b.scheme()
304        && a.host_str() == b.host_str()
305        && a.port_or_known_default() == b.port_or_known_default()
306}
307
308/// Canonical `scheme://host[:port]` serialisation of a URL's origin, with the
309/// scheme's default port dropped (so `https://h` and `https://h:443` collapse to
310/// one key). Returns `None` for a URL that does not parse or whose origin is
311/// opaque (e.g. `data:`), which are never valid allowlist entries.
312fn canonical_origin(s: &str) -> Option<String> {
313    let origin = url::Url::parse(s.trim()).ok()?.origin();
314    origin.is_tuple().then(|| origin.ascii_serialization())
315}
316
317/// A per-connecting-origin outbound allowlist for the bridge.
318///
319/// Maps each **connecting tab origin** (the `Origin` presented at the WebSocket
320/// upgrade) to the set of **outbound origins** a request routed to that tab may
321/// reach. This is the mechanism behind per-origin scoping: a Grafana tab and a
322/// Facebook tab each carry only their own grant, so neither can borrow the
323/// other's outbound scope.
324///
325/// Built from repeatable `--allow-origin` values, each either a bare `ORIGIN`
326/// (shorthand: the tab may reach its own origin) or an explicit
327/// `CONNECT=OUTBOUND` mapping; repeats accumulate outbound origins under the same
328/// connecting key. An empty allowlist is the unconfigured default — the WS gate
329/// admits any origin (the session token is the gate) and outbound scope is
330/// default-closed (relative URLs only).
331#[derive(Debug, Clone, Default, PartialEq, Eq)]
332pub struct OriginAllowlist {
333    /// Canonical connecting-origin → canonical permitted outbound origins.
334    map: BTreeMap<String, BTreeSet<String>>,
335}
336
337impl OriginAllowlist {
338    /// Parses repeatable `--allow-origin` CLI values into an allowlist.
339    ///
340    /// Each value is `ORIGIN` (shorthand for `ORIGIN=ORIGIN`) or
341    /// `CONNECT=OUTBOUND`. Both sides must be valid, non-opaque origins; a
342    /// malformed or empty side is a hard error naming the offending value.
343    pub fn parse<S: AsRef<str>>(values: &[S]) -> Result<Self, String> {
344        let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
345        for raw in values {
346            let raw = raw.as_ref().trim();
347            if raw.is_empty() {
348                return Err("empty --allow-origin value".to_string());
349            }
350            let (connect, outbound) = match raw.split_once('=') {
351                Some((c, o)) => (c.trim(), o.trim()),
352                None => (raw, raw),
353            };
354            let connect = canonical_origin(connect).ok_or_else(|| {
355                format!("invalid --allow-origin connecting origin: {connect:?} (in {raw:?})")
356            })?;
357            let outbound = canonical_origin(outbound).ok_or_else(|| {
358                format!("invalid --allow-origin outbound origin: {outbound:?} (in {raw:?})")
359            })?;
360            map.entry(connect).or_default().insert(outbound);
361        }
362        Ok(Self { map })
363    }
364
365    /// Whether no `--allow-origin` was configured (the default-open WS gate,
366    /// default-closed outbound case).
367    #[must_use]
368    pub fn is_empty(&self) -> bool {
369        self.map.is_empty()
370    }
371
372    /// Whether a WebSocket upgrade from `origin` is permitted.
373    ///
374    /// An empty allowlist admits any origin (the token in the subprotocol is the
375    /// gate). Otherwise the connecting origin must be a configured key — an
376    /// origin-less upgrade is rejected once any entry exists.
377    #[must_use]
378    pub fn permits_connection(&self, origin: Option<&str>) -> bool {
379        if self.map.is_empty() {
380            return true;
381        }
382        origin
383            .and_then(canonical_origin)
384            .is_some_and(|o| self.map.contains_key(&o))
385    }
386
387    /// The outbound origins granted to a request routed to a tab on `origin`.
388    ///
389    /// Empty when the tab's origin is unknown or carries no grant — leaving only
390    /// relative URLs permitted (see [`validate_outbound_url`]).
391    #[must_use]
392    pub fn outbound_for(&self, origin: Option<&str>) -> Vec<&str> {
393        origin
394            .and_then(canonical_origin)
395            .and_then(|o| self.map.get(&o))
396            .map(|set| set.iter().map(String::as_str).collect())
397            .unwrap_or_default()
398    }
399
400    /// Renders the configured entries as `CONNECT → OUTBOUND[, OUTBOUND…]` lines
401    /// (connecting-origin sorted) for the startup banner. Empty when unconfigured.
402    #[must_use]
403    pub fn describe(&self) -> Vec<String> {
404        self.map
405            .iter()
406            .map(|(connect, outbound)| {
407                let outbound = outbound.iter().cloned().collect::<Vec<_>>().join(", ");
408                format!("{connect} → {outbound}")
409            })
410            .collect()
411    }
412}
413
414/// Extracts and verifies the bridge token from WebSocket subprotocols.
415///
416/// Returns the matching subprotocol to echo back in the handshake response, or
417/// `None` if no presented protocol matches the expected token.
418#[must_use]
419pub fn ws_subprotocol_token<'a, I>(subprotocols: I, token: &str) -> Option<&'a str>
420where
421    I: IntoIterator<Item = &'a str>,
422{
423    subprotocols
424        .into_iter()
425        .map(str::trim)
426        .find(|p| constant_time_eq(p, token))
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used, clippy::expect_used)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn generated_tokens_are_unique_and_urlsafe() {
436        let a = generate_token();
437        let b = generate_token();
438        assert_ne!(a, b);
439        assert!(a
440            .chars()
441            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
442        assert!(a.len() >= 40);
443    }
444
445    #[test]
446    fn constant_time_eq_matches_str_eq() {
447        assert!(constant_time_eq("abc", "abc"));
448        assert!(!constant_time_eq("abc", "abd"));
449        assert!(!constant_time_eq("abc", "abcd"));
450    }
451
452    #[test]
453    fn bearer_accepts_only_correct_token() {
454        assert!(bearer_matches(Some("Bearer tok"), "tok"));
455        assert!(!bearer_matches(Some("Bearer wrong"), "tok"));
456        assert!(!bearer_matches(Some("tok"), "tok"));
457        assert!(!bearer_matches(None, "tok"));
458    }
459
460    #[test]
461    fn bridge_header_must_be_one() {
462        assert!(has_bridge_header(Some("1")));
463        assert!(has_bridge_header(Some(" 1 ")));
464        assert!(!has_bridge_header(Some("0")));
465        assert!(!has_bridge_header(None));
466    }
467
468    #[test]
469    fn host_allowlist_blocks_rebinding() {
470        assert!(host_allowed("localhost:9998", 9998));
471        assert!(host_allowed("127.0.0.1:9998", 9998));
472        assert!(host_allowed("[::1]:9998", 9998));
473        assert!(!host_allowed("evil.example.com:9998", 9998));
474        assert!(!host_allowed("localhost:9999", 9998));
475        assert!(!host_allowed("localhost", 9998));
476    }
477
478    #[test]
479    fn browser_origin_is_rejected() {
480        assert!(is_browser_originated(Some("https://evil.test"), None));
481        assert!(is_browser_originated(None, Some("cross-site")));
482        assert!(is_browser_originated(None, Some("same-site")));
483        assert!(!is_browser_originated(None, None));
484        assert!(!is_browser_originated(None, Some("none")));
485    }
486
487    #[test]
488    fn header_crlf_is_rejected() {
489        assert!(header_is_safe("Accept", "application/json"));
490        assert!(!header_is_safe("X\r\nEvil", "v"));
491        assert!(!header_is_safe("X", "a\r\nSet-Cookie: y"));
492        assert!(!header_is_safe("", "v"));
493    }
494
495    #[test]
496    fn path_normalization_rejects_traversal() {
497        assert_eq!(
498            normalize_request_path("/loki/api/v1/labels").as_deref(),
499            Some("/loki/api/v1/labels")
500        );
501        assert_eq!(
502            normalize_request_path("/a/%2e%2e/b"),
503            Some("/a/../b".to_string()).filter(|_| false).or(None)
504        );
505        assert!(normalize_request_path("/a/../b").is_none());
506        assert!(normalize_request_path("/a/%2e%2e/b").is_none());
507        assert!(normalize_request_path("/a/%00/b").is_none());
508        assert!(normalize_request_path("/bad%2").is_none());
509    }
510
511    #[test]
512    fn outbound_scope_is_default_closed() {
513        assert_eq!(validate_outbound_url("/api/foo", &[]), Ok(()));
514        assert_eq!(
515            validate_outbound_url("https://evil.test/x", &[]),
516            Err(ScopeError::CrossOriginDenied)
517        );
518        assert_eq!(
519            validate_outbound_url("//evil.test/x", &[]),
520            Err(ScopeError::CrossOriginDenied)
521        );
522    }
523
524    #[test]
525    fn outbound_scope_honors_allowed_origins() {
526        assert_eq!(
527            validate_outbound_url("https://ok.test/x", &["https://ok.test"]),
528            Ok(())
529        );
530        assert_eq!(
531            validate_outbound_url("https://evil.test/x", &["https://ok.test"]),
532            Err(ScopeError::CrossOriginDenied)
533        );
534        // Any origin in the slice permits the URL.
535        assert_eq!(
536            validate_outbound_url("https://b.test/x", &["https://a.test", "https://b.test"]),
537            Ok(())
538        );
539        // Relative always allowed regardless of the outbound grant.
540        assert_eq!(validate_outbound_url("/x", &["https://ok.test"]), Ok(()));
541    }
542
543    #[test]
544    fn ws_subprotocol_token_selects_match() {
545        assert_eq!(ws_subprotocol_token(["a", "tok", "b"], "tok"), Some("tok"));
546        assert_eq!(ws_subprotocol_token(["a", "b"], "tok"), None);
547        assert_eq!(ws_subprotocol_token([], "tok"), None);
548    }
549
550    #[test]
551    fn empty_allowlist_opens_the_ws_gate() {
552        let empty = OriginAllowlist::default();
553        assert!(empty.is_empty());
554        // Token is the gate: any origin (or none) connects.
555        assert!(empty.permits_connection(Some("https://anything.test")));
556        assert!(empty.permits_connection(None));
557        // ...and outbound is default-closed (no origins granted).
558        assert!(empty.outbound_for(Some("https://anything.test")).is_empty());
559    }
560
561    #[test]
562    fn allowlist_gates_connection_by_configured_key() {
563        let list = OriginAllowlist::parse(&["https://ok.test"]).unwrap();
564        assert!(list.permits_connection(Some("https://ok.test")));
565        // Port normalisation: the default https port collapses onto the key.
566        assert!(list.permits_connection(Some("https://ok.test:443")));
567        assert!(!list.permits_connection(Some("https://evil.test")));
568        // An origin-less upgrade is rejected once any entry exists.
569        assert!(!list.permits_connection(None));
570    }
571
572    #[test]
573    fn shorthand_grants_a_tab_its_own_origin() {
574        let list = OriginAllowlist::parse(&["https://grafana.internal"]).unwrap();
575        assert_eq!(
576            list.outbound_for(Some("https://grafana.internal")),
577            vec!["https://grafana.internal"]
578        );
579        // A tab with no matching key carries no outbound grant.
580        assert!(list.outbound_for(Some("https://other.test")).is_empty());
581        assert!(list.outbound_for(None).is_empty());
582    }
583
584    #[test]
585    fn mapping_scopes_outbound_per_connecting_origin() {
586        // A Grafana tab and a Facebook tab each carry only their own grant.
587        let list = OriginAllowlist::parse(&[
588            "https://grafana.internal",
589            "https://www.facebook.com=https://static.xx.fbcdn.net",
590        ])
591        .unwrap();
592        assert_eq!(
593            list.outbound_for(Some("https://grafana.internal")),
594            vec!["https://grafana.internal"]
595        );
596        assert_eq!(
597            list.outbound_for(Some("https://www.facebook.com")),
598            vec!["https://static.xx.fbcdn.net"]
599        );
600        // The Grafana tab cannot borrow Facebook's outbound scope.
601        assert_eq!(
602            validate_outbound_url(
603                "https://static.xx.fbcdn.net/x",
604                &list.outbound_for(Some("https://grafana.internal"))
605            ),
606            Err(ScopeError::CrossOriginDenied)
607        );
608    }
609
610    #[test]
611    fn repeated_keys_accumulate_outbound_origins() {
612        let list = OriginAllowlist::parse(&[
613            "https://app.test=https://a.cdn.test",
614            "https://app.test=https://b.cdn.test",
615        ])
616        .unwrap();
617        assert_eq!(
618            list.outbound_for(Some("https://app.test")),
619            vec!["https://a.cdn.test", "https://b.cdn.test"]
620        );
621    }
622
623    #[test]
624    fn parse_rejects_malformed_and_empty_values() {
625        assert!(OriginAllowlist::parse(&["not a url"]).is_err());
626        assert!(OriginAllowlist::parse(&["https://ok.test=nonsense"]).is_err());
627        assert!(OriginAllowlist::parse(&[""]).is_err());
628        assert!(OriginAllowlist::parse(&["=https://ok.test"]).is_err());
629    }
630
631    #[cfg(unix)]
632    #[test]
633    fn token_file_requires_0600() {
634        use std::io::Write;
635        use std::os::unix::fs::PermissionsExt;
636        let dir = tempfile::tempdir().unwrap();
637        let path = dir.path().join("tok");
638        let mut f = std::fs::File::create(&path).unwrap();
639        writeln!(f, "secret-token").unwrap();
640        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
641        assert!(resolve_token(Some(&path)).is_err());
642        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
643        assert_eq!(resolve_token(Some(&path)).unwrap(), "secret-token");
644    }
645
646    // ── token resolution from env / file ─────────────────────────────
647    //
648    // `OMNI_BRIDGE_TOKEN` is read through an injected `EnvSource`, so these
649    // tests pass a pure `MapEnv` and never touch the process environment —
650    // no lock, fully parallel (issue #1030).
651
652    use crate::test_support::env::MapEnv;
653
654    #[test]
655    fn resolve_token_reads_trimmed_env_var() {
656        let env = MapEnv::new().with(TOKEN_ENV, "  env-token  ");
657        assert_eq!(resolve_token_with(&env, None).unwrap(), "env-token");
658    }
659
660    #[test]
661    fn resolve_token_generates_when_env_empty_or_absent() {
662        // Absent → freshly generated (long, URL-safe).
663        let a = resolve_token_with(&MapEnv::new(), None).unwrap();
664        assert!(a.len() >= 40);
665        // Blank/whitespace env is ignored and also generates.
666        let env = MapEnv::new().with(TOKEN_ENV, "   ");
667        let b = resolve_token_with(&env, None).unwrap();
668        assert!(b.len() >= 40);
669        assert_ne!(a, b);
670    }
671
672    #[test]
673    fn resolve_existing_token_reads_env_var() {
674        let env = MapEnv::new().with(TOKEN_ENV, "client-token");
675        assert_eq!(
676            resolve_existing_token_with(&env, None).unwrap(),
677            "client-token"
678        );
679    }
680
681    #[test]
682    fn resolve_existing_token_errors_without_source() {
683        let err = resolve_existing_token_with(&MapEnv::new(), None).unwrap_err();
684        assert!(err.to_string().contains(TOKEN_ENV));
685    }
686
687    #[test]
688    fn resolve_existing_token_reads_file() {
689        let dir = tempfile::tempdir().unwrap();
690        let path = dir.path().join("tok");
691        std::fs::write(&path, "  file-token\n").unwrap();
692        #[cfg(unix)]
693        {
694            use std::os::unix::fs::PermissionsExt;
695            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
696        }
697        assert_eq!(resolve_existing_token(Some(&path)).unwrap(), "file-token");
698    }
699
700    #[test]
701    fn token_file_missing_errors() {
702        let dir = tempfile::tempdir().unwrap();
703        let path = dir.path().join("does-not-exist");
704        assert!(resolve_token(Some(&path)).is_err());
705    }
706
707    #[test]
708    fn token_file_empty_errors() {
709        let dir = tempfile::tempdir().unwrap();
710        let path = dir.path().join("empty");
711        std::fs::write(&path, "   \n").unwrap();
712        #[cfg(unix)]
713        {
714            use std::os::unix::fs::PermissionsExt;
715            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
716        }
717        let err = resolve_token(Some(&path)).unwrap_err();
718        assert!(err.to_string().contains("empty"));
719    }
720}