metrale_server/
auth.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Bearer-token validation for the HTTP API's `--require-auth` gate.
4//!
5//! Owner: server auth.
6//! Invariants:
7//! - An `AuthConfig` holds at least one non-empty token: both constructors
8//!   return an error otherwise.
9//! - `validate` compares the presented token with every loaded token, and
10//!   compares equal-length tokens over every byte with no early exit.
11//!
12//! `build_auth_config` (`main_modules/serve.rs`) loads the tokens once at
13//! startup, from `--auth-tokens-file` or `--auth-token`, and only when
14//! `--require-auth` is set. For `--auth-token` it logs a warning that the
15//! value is visible through `ps`/`/proc/<pid>/cmdline`. File permissions are
16//! not checked.
17
18use std::path::Path;
19
20use anyhow::{Context, Result, anyhow};
21
22/// 2026-09-26: The loaded bearer tokens. The server shares one instance as
23/// `Arc<AuthConfig>`.
24#[derive(Debug)]
25pub struct AuthConfig {
26    /// 2026-09-26: The tokens as bytes; `from_file` sorts and de-duplicates them.
27    tokens: Vec<Vec<u8>>,
28}
29
30impl AuthConfig {
31    /// 2026-09-26: Load tokens from a file, one per line. Each line is trimmed
32    /// at both ends; empty lines and lines starting with `#` are skipped.
33    /// Errors when the file cannot be read or no token is left.
34    pub fn from_file(path: &Path) -> Result<Self> {
35        let raw = std::fs::read_to_string(path)
36            .with_context(|| format!("reading auth tokens file {}", path.display()))?;
37        let mut tokens: Vec<Vec<u8>> = raw
38            .lines()
39            .map(str::trim)
40            .filter(|s| !s.is_empty() && !s.starts_with('#'))
41            .map(|s| s.as_bytes().to_vec())
42            .collect();
43        tokens.sort();
44        tokens.dedup();
45        if tokens.is_empty() {
46            return Err(anyhow!(
47                "auth tokens file {} contains no usable tokens \
48                 (lines must be non-empty and not start with `#`)",
49                path.display()
50            ));
51        }
52        Ok(Self { tokens })
53    }
54
55    /// 2026-09-26: One inline token, trimmed at both ends. An empty or
56    /// all-whitespace token is an error.
57    pub fn from_inline(token: &str) -> Result<Self> {
58        let trimmed = token.trim();
59        if trimmed.is_empty() {
60            return Err(anyhow!("--auth-token must not be empty"));
61        }
62        Ok(Self {
63            tokens: vec![trimmed.as_bytes().to_vec()],
64        })
65    }
66
67    /// 2026-09-26: Number of distinct tokens loaded; the startup log in
68    /// `build_auth_config` prints this count.
69    pub fn token_count(&self) -> usize {
70        self.tokens.len()
71    }
72
73    /// 2026-09-26: `true` iff `presented` byte-equals one of the loaded tokens.
74    /// Every loaded token is compared, even after a match, and each
75    /// comparison is `ct_eq`, so the time does not depend on how many
76    /// leading bytes match.
77    pub fn validate(&self, presented: &[u8]) -> bool {
78        self.tenant_of(presented).is_some()
79    }
80
81    /// 2026-10-04: The tenant `presented` authenticates as: 1 + the index of
82    /// the loaded token it equals (tokens are sorted, so the index is stable
83    /// for one tokens file); `None` when it equals none. Same constant-time
84    /// comparison as [`Self::validate`].
85    pub fn tenant_of(&self, presented: &[u8]) -> Option<LookupTenant> {
86        let mut found = 0u64;
87        for (i, valid) in self.tokens.iter().enumerate() {
88            found |= u64::from(ct_eq(presented, valid)) * (i as u64 + 1);
89        }
90        (found != 0).then_some(LookupTenant(found))
91    }
92}
93
94/// 2026-10-04: Who a request belongs to, for state shared across requests
95/// that must not cross tenants (the cross-request prompt-lookup cache). The
96/// auth middleware attaches it to every request: `0` when the serve has no
97/// `--require-auth` (one trust domain), else the token's tenant
98/// ([`AuthConfig::tenant_of`]).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct LookupTenant(pub u64);
101
102impl LookupTenant {
103    /// 2026-10-04: The tenant of a serve without `--require-auth`.
104    pub const OPEN: Self = Self(0);
105}
106
107/// 2026-09-26: `1` if the slices are byte-equal, `0` otherwise. A length
108/// mismatch returns `0` at once, so the length is observable through timing;
109/// equal-length slices are compared over every byte with no early exit, so
110/// the time does not show how many leading bytes match.
111fn ct_eq(a: &[u8], b: &[u8]) -> u8 {
112    if a.len() != b.len() {
113        return 0;
114    }
115    let mut diff: u8 = 0;
116    for (x, y) in a.iter().zip(b.iter()) {
117        diff |= x ^ y;
118    }
119    // 2026-09-26: 1 when `diff == 0`, else 0, without a branch: only
120    // `0u32.wrapping_sub(1)` has bit 31 set.
121    1u8 & ((diff as u32).wrapping_sub(1) >> 31) as u8
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn ct_eq_matches_only_when_equal() {
130        assert_eq!(ct_eq(b"hello", b"hello"), 1);
131        assert_eq!(ct_eq(b"hello", b"world"), 0);
132        assert_eq!(ct_eq(b"hello", b"hellz"), 0);
133        assert_eq!(ct_eq(b"hello", b"helloo"), 0);
134        assert_eq!(ct_eq(b"", b""), 1);
135        assert_eq!(ct_eq(b"a", b""), 0);
136    }
137
138    #[test]
139    fn validates_single_inline_token() {
140        let cfg = AuthConfig::from_inline("sk-test-token").unwrap();
141        assert!(cfg.validate(b"sk-test-token"));
142        assert!(!cfg.validate(b"sk-test-toke"));
143        assert!(!cfg.validate(b"sk-test-tokenx"));
144        assert!(!cfg.validate(b""));
145        assert_eq!(cfg.token_count(), 1);
146    }
147
148    #[test]
149    fn rejects_empty_inline() {
150        assert!(AuthConfig::from_inline("").is_err());
151        assert!(AuthConfig::from_inline("   ").is_err());
152    }
153
154    #[test]
155    fn loads_file_with_comments_and_blanks() {
156        let dir = tempfile::tempdir().unwrap();
157        let path = dir.path().join("tokens.txt");
158        std::fs::write(
159            &path,
160            "# project A\n\
161             alpha-token\n\
162             \n\
163             # project B\n\
164             beta-token\n\
165             alpha-token\n",
166        )
167        .unwrap();
168        let cfg = AuthConfig::from_file(&path).unwrap();
169        assert_eq!(cfg.token_count(), 2);
170        assert!(cfg.validate(b"alpha-token"));
171        assert!(cfg.validate(b"beta-token"));
172        assert!(!cfg.validate(b"# project A"));
173        assert!(!cfg.validate(b"gamma-token"));
174        // 2026-10-04: Each token is its own tenant, never the open serve's.
175        let (a, b) = (cfg.tenant_of(b"alpha-token"), cfg.tenant_of(b"beta-token"));
176        assert!(a.is_some() && b.is_some() && a != b);
177        assert!(a != Some(LookupTenant::OPEN) && b != Some(LookupTenant::OPEN));
178        assert_eq!(cfg.tenant_of(b"gamma-token"), None);
179        assert_eq!(cfg.tenant_of(b"alpha-toke"), None);
180    }
181
182    #[test]
183    fn empty_file_is_error() {
184        let dir = tempfile::tempdir().unwrap();
185        let path = dir.path().join("empty.txt");
186        std::fs::write(&path, "# only a comment\n\n   \n").unwrap();
187        assert!(AuthConfig::from_file(&path).is_err());
188    }
189}