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}