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