spark_server/
auth.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Bearer-token authentication for the HTTP API.
4//!
5//! Atlas serves an OpenAI-compatible API; every mainstream client library
6//! (`openai`, `litellm`, `anthropic`, opencode, OpenWebUI) sends
7//! `Authorization: Bearer <key>` by default, so bearer tokens are the only
8//! auth scheme that preserves drop-in client compatibility. mTLS and signed
9//! requests would force every user to re-tool their client, so we don't ship
10//! them. (If an enterprise customer needs mTLS in front of Atlas, the
11//! standard answer is a reverse proxy — nginx, Envoy, Caddy — terminating
12//! TLS and forwarding to Atlas on localhost.)
13//!
14//! Tokens are loaded once at startup. Two sources are supported:
15//!   - `--auth-tokens-file <PATH>`: one token per line, blank lines and
16//!     `#` comments ignored. The standard production form. Permissions
17//!     should be `0600` and a warning is logged if they're broader.
18//!   - `--auth-token <TOKEN>`: single inline token. Convenient for quick
19//!     starts but the token leaks via `ps`/`/proc/<pid>/cmdline`, so the
20//!     server logs a one-line warning at startup.
21//!
22//! Validation uses constant-time byte comparison (no early-exit on first
23//! mismatch) so an attacker can't measure token-prefix-match latency to
24//! recover the secret. Comparing against multiple candidate tokens linearly
25//! is acceptable here — the candidate set is tiny (operator-curated),
26//! constant-time-bounded, and not under attacker control.
27
28use std::path::Path;
29
30use anyhow::{Context, Result, anyhow};
31
32/// Loaded bearer-token validator. Constructed once at startup; cloneable
33/// across handlers via `Arc<AuthConfig>`.
34#[derive(Debug)]
35pub struct AuthConfig {
36    /// Valid bearer tokens, stored as raw bytes for constant-time compare.
37    /// Order is irrelevant; duplicates are de-duped at load time.
38    tokens: Vec<Vec<u8>>,
39}
40
41impl AuthConfig {
42    /// Load tokens from a file. One token per line; blank lines and lines
43    /// starting with `#` are ignored. Trailing whitespace is trimmed from
44    /// each token. Returns an error if the file is empty after parsing.
45    pub fn from_file(path: &Path) -> Result<Self> {
46        let raw = std::fs::read_to_string(path)
47            .with_context(|| format!("reading auth tokens file {}", path.display()))?;
48        let mut tokens: Vec<Vec<u8>> = raw
49            .lines()
50            .map(str::trim)
51            .filter(|s| !s.is_empty() && !s.starts_with('#'))
52            .map(|s| s.as_bytes().to_vec())
53            .collect();
54        tokens.sort();
55        tokens.dedup();
56        if tokens.is_empty() {
57            return Err(anyhow!(
58                "auth tokens file {} contains no usable tokens \
59                 (lines must be non-empty and not start with `#`)",
60                path.display()
61            ));
62        }
63        Ok(Self { tokens })
64    }
65
66    /// Build from a single inline token. The caller is expected to have
67    /// already trimmed; we trim defensively to catch obvious mistakes.
68    pub fn from_inline(token: &str) -> Result<Self> {
69        let trimmed = token.trim();
70        if trimmed.is_empty() {
71            return Err(anyhow!("--auth-token must not be empty"));
72        }
73        Ok(Self {
74            tokens: vec![trimmed.as_bytes().to_vec()],
75        })
76    }
77
78    /// Number of distinct tokens loaded. For startup logging only —
79    /// never log the tokens themselves.
80    pub fn token_count(&self) -> usize {
81        self.tokens.len()
82    }
83
84    /// Validate a presented token in constant time relative to the length
85    /// of the candidate set. Returns `true` iff the presented token byte-
86    /// equals one of the loaded tokens.
87    ///
88    /// The comparison is constant-time per candidate (no early exit on
89    /// first mismatching byte); the loop over candidates is linear, which
90    /// is fine because the operator controls the candidate count and it
91    /// is small (typically 1–10). An attacker cannot insert candidates,
92    /// so the linear scan does not leak exploitable timing information.
93    pub fn validate(&self, presented: &[u8]) -> bool {
94        let mut any_match = 0u8;
95        for valid in &self.tokens {
96            any_match |= ct_eq(presented, valid);
97        }
98        any_match == 1
99    }
100}
101
102/// Constant-time byte-slice equality. Returns `1` if the slices are
103/// byte-equal, `0` otherwise. Always processes `max(a.len(), b.len())`
104/// bytes — never short-circuits on first mismatch — so timing does not
105/// leak how many leading bytes match.
106fn ct_eq(a: &[u8], b: &[u8]) -> u8 {
107    // Length mismatch is observable through length alone; that leak is
108    // intentional and unavoidable (a token's length is not secret in
109    // practice — generators emit fixed-length tokens).
110    if a.len() != b.len() {
111        return 0;
112    }
113    let mut diff: u8 = 0;
114    for (x, y) in a.iter().zip(b.iter()) {
115        diff |= x ^ y;
116    }
117    // diff == 0 ⇒ slices are equal. Convert to {0, 1} without a branch.
118    1u8 & ((diff as u32).wrapping_sub(1) >> 31) as u8
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn ct_eq_matches_only_when_equal() {
127        assert_eq!(ct_eq(b"hello", b"hello"), 1);
128        assert_eq!(ct_eq(b"hello", b"world"), 0);
129        assert_eq!(ct_eq(b"hello", b"hellz"), 0);
130        assert_eq!(ct_eq(b"hello", b"helloo"), 0);
131        assert_eq!(ct_eq(b"", b""), 1);
132        assert_eq!(ct_eq(b"a", b""), 0);
133    }
134
135    #[test]
136    fn validates_single_inline_token() {
137        let cfg = AuthConfig::from_inline("sk-test-token").unwrap();
138        assert!(cfg.validate(b"sk-test-token"));
139        assert!(!cfg.validate(b"sk-test-toke"));
140        assert!(!cfg.validate(b"sk-test-tokenx"));
141        assert!(!cfg.validate(b""));
142        assert_eq!(cfg.token_count(), 1);
143    }
144
145    #[test]
146    fn rejects_empty_inline() {
147        assert!(AuthConfig::from_inline("").is_err());
148        assert!(AuthConfig::from_inline("   ").is_err());
149    }
150
151    #[test]
152    fn loads_file_with_comments_and_blanks() {
153        let dir = tempfile::tempdir().unwrap();
154        let path = dir.path().join("tokens.txt");
155        std::fs::write(
156            &path,
157            "# project A\n\
158             alpha-token\n\
159             \n\
160             # project B\n\
161             beta-token\n\
162             alpha-token\n", // duplicate — should be de-duped
163        )
164        .unwrap();
165        let cfg = AuthConfig::from_file(&path).unwrap();
166        assert_eq!(cfg.token_count(), 2);
167        assert!(cfg.validate(b"alpha-token"));
168        assert!(cfg.validate(b"beta-token"));
169        assert!(!cfg.validate(b"# project A"));
170        assert!(!cfg.validate(b"gamma-token"));
171    }
172
173    #[test]
174    fn empty_file_is_error() {
175        let dir = tempfile::tempdir().unwrap();
176        let path = dir.path().join("empty.txt");
177        std::fs::write(&path, "# only a comment\n\n   \n").unwrap();
178        assert!(AuthConfig::from_file(&path).is_err());
179    }
180}