1use std::path::Path;
19
20use anyhow::{Context, Result, anyhow};
21
22#[derive(Debug)]
25pub struct AuthConfig {
26 tokens: Vec<Vec<u8>>,
28}
29
30impl AuthConfig {
31 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 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 pub fn token_count(&self) -> usize {
70 self.tokens.len()
71 }
72
73 pub fn validate(&self, presented: &[u8]) -> bool {
78 self.tenant_of(presented).is_some()
79 }
80
81 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct LookupTenant(pub u64);
101
102impl LookupTenant {
103 pub const OPEN: Self = Self(0);
105}
106
107fn 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 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 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}