1use std::collections::HashMap;
4use std::sync::{Arc, Mutex, RwLock};
5use std::time::{Duration, Instant};
6
7use mcp_usage_core::PriceBook;
8use sha2::{Digest, Sha256};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Tenant {
13 pub id: String,
15 pub billing_customer_id: String,
17 pub prices: PriceBook,
19}
20
21impl Tenant {
22 #[must_use]
24 pub fn new(id: impl Into<String>, billing_customer_id: impl Into<String>) -> Self {
25 Self {
26 id: id.into(),
27 billing_customer_id: billing_customer_id.into(),
28 prices: PriceBook::default(),
29 }
30 }
31
32 #[must_use]
34 pub fn with_prices(mut self, prices: PriceBook) -> Self {
35 self.prices = prices;
36 self
37 }
38}
39
40pub trait TenantStore: Send + Sync {
42 fn authenticate(&self, api_key: &str) -> Option<Arc<Tenant>>;
49}
50
51#[must_use]
56pub fn hash_api_key(api_key: &str) -> String {
57 const HEX: &[u8; 16] = b"0123456789abcdef";
58 let digest = Sha256::digest(api_key.as_bytes());
59 let mut encoded = String::with_capacity(digest.len() * 2);
60 for byte in digest {
61 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
62 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
63 }
64 encoded
65}
66
67#[derive(Debug)]
83pub(crate) struct AuthFailureLimit {
84 max_failures: u64,
85 window: Duration,
86 state: Mutex<FailureWindow>,
87}
88
89#[derive(Debug)]
90struct FailureWindow {
91 started: Instant,
92 failures: u64,
93}
94
95impl AuthFailureLimit {
96 pub(crate) fn new(max_failures: u64, window: Duration) -> Self {
97 Self {
98 max_failures,
99 window,
100 state: Mutex::new(FailureWindow {
101 started: Instant::now(),
102 failures: 0,
103 }),
104 }
105 }
106
107 pub(crate) fn is_exhausted(&self) -> bool {
109 let mut state = self
110 .state
111 .lock()
112 .unwrap_or_else(std::sync::PoisonError::into_inner);
113 Self::expire(&mut state, self.window);
114 state.failures >= self.max_failures
115 }
116
117 pub(crate) fn record_failure(&self) {
119 let mut state = self
120 .state
121 .lock()
122 .unwrap_or_else(std::sync::PoisonError::into_inner);
123 Self::expire(&mut state, self.window);
124 state.failures = state.failures.saturating_add(1);
125 }
126
127 fn expire(state: &mut FailureWindow, window: Duration) {
128 if state.started.elapsed() >= window {
129 state.started = Instant::now();
130 state.failures = 0;
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum WeakApiKey {
140 TooShort,
142 TooFewDistinctSymbols,
144 TooPredictable,
146}
147
148impl std::fmt::Display for WeakApiKey {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.write_str(match self {
151 Self::TooShort => "API key is shorter than 24 bytes",
152 Self::TooFewDistinctSymbols => "API key uses fewer than 10 distinct characters",
153 Self::TooPredictable => "API key symbol distribution carries too little entropy",
154 })
155 }
156}
157
158impl std::error::Error for WeakApiKey {}
159
160pub const MIN_API_KEY_BYTES: usize = 24;
162const MIN_DISTINCT_SYMBOLS: usize = 10;
163const MIN_BITS_PER_SYMBOL: f64 = 3.0;
164
165pub fn validate_api_key_strength(api_key: &str) -> Result<(), WeakApiKey> {
182 let bytes = api_key.as_bytes();
183 if bytes.len() < MIN_API_KEY_BYTES {
184 return Err(WeakApiKey::TooShort);
185 }
186
187 let mut counts = [0usize; 256];
188 for &byte in bytes {
189 counts[usize::from(byte)] += 1;
190 }
191 let distinct = counts.iter().filter(|count| **count > 0).count();
192 if distinct < MIN_DISTINCT_SYMBOLS {
193 return Err(WeakApiKey::TooFewDistinctSymbols);
194 }
195
196 #[expect(
198 clippy::cast_precision_loss,
199 reason = "key lengths are far below the f64 integer precision limit"
200 )]
201 let total = bytes.len() as f64;
202 let bits_per_symbol: f64 = counts
203 .iter()
204 .filter(|count| **count > 0)
205 .map(|count| {
206 #[expect(
207 clippy::cast_precision_loss,
208 reason = "counts are bounded by the key length"
209 )]
210 let probability = *count as f64 / total;
211 -probability * probability.log2()
212 })
213 .sum();
214 if bits_per_symbol < MIN_BITS_PER_SYMBOL {
215 return Err(WeakApiKey::TooPredictable);
216 }
217 Ok(())
218}
219
220#[derive(Clone, Default)]
222pub struct InMemoryTenantStore {
223 by_key_hash: Arc<RwLock<HashMap<String, Arc<Tenant>>>>,
224}
225
226impl std::fmt::Debug for InMemoryTenantStore {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.debug_struct("InMemoryTenantStore")
229 .field("configured_keys", &self.len())
230 .finish_non_exhaustive()
231 }
232}
233
234impl InMemoryTenantStore {
235 #[must_use]
237 pub fn new() -> Self {
238 Self::default()
239 }
240
241 pub fn insert(&self, api_key: &str, tenant: Tenant) -> Result<(), WeakApiKey> {
252 validate_api_key_strength(api_key)?;
253 self.insert_unchecked(api_key, tenant);
254 Ok(())
255 }
256
257 pub fn insert_unchecked(&self, api_key: &str, tenant: Tenant) {
263 let mut tenants = self
264 .by_key_hash
265 .write()
266 .unwrap_or_else(std::sync::PoisonError::into_inner);
267 tenants.insert(hash_api_key(api_key), Arc::new(tenant));
268 }
269
270 #[must_use]
272 pub fn len(&self) -> usize {
273 self.by_key_hash
274 .read()
275 .unwrap_or_else(std::sync::PoisonError::into_inner)
276 .len()
277 }
278
279 #[must_use]
281 pub fn is_empty(&self) -> bool {
282 self.len() == 0
283 }
284}
285
286impl TenantStore for InMemoryTenantStore {
287 fn authenticate(&self, api_key: &str) -> Option<Arc<Tenant>> {
288 self.by_key_hash
289 .read()
290 .unwrap_or_else(std::sync::PoisonError::into_inner)
291 .get(&hash_api_key(api_key))
292 .cloned()
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn realistic_generated_keys_are_accepted() {
302 for key in [
303 "kZ2mQx7vR4tYbN8pL1sW3eH6jA5cF0uD9gK2nM4oP7Q=",
305 "9f3a1c7e5b2d8046af91c3e57d0b2a64",
307 "mcp_sk_live_7Hq2vXm9RtZp4LbN6wCk3JdY",
309 ] {
310 assert_eq!(validate_api_key_strength(key), Ok(()), "rejected {key}");
311 }
312 }
313
314 #[test]
315 fn obviously_weak_keys_are_refused() {
316 for (key, expected) in [
317 ("secret", WeakApiKey::TooShort),
318 ("development-key", WeakApiKey::TooShort),
319 ("password123", WeakApiKey::TooShort),
320 (
322 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
323 WeakApiKey::TooFewDistinctSymbols,
324 ),
325 (
326 "abababababababababababababababab",
327 WeakApiKey::TooFewDistinctSymbols,
328 ),
329 (
331 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabcdefghij",
332 WeakApiKey::TooPredictable,
333 ),
334 ] {
335 assert_eq!(
336 validate_api_key_strength(key),
337 Err(expected),
338 "accepted {key}"
339 );
340 }
341 }
342
343 #[test]
344 fn the_validating_insert_refuses_to_store_a_weak_key() {
345 let store = InMemoryTenantStore::new();
346 assert_eq!(
347 store.insert("development-key", Tenant::new("acme", "cus_acme")),
348 Err(WeakApiKey::TooShort)
349 );
350 assert!(store.is_empty(), "a refused key must not be stored");
351 assert!(store.authenticate("development-key").is_none());
352
353 store.insert_unchecked("development-key", Tenant::new("acme", "cus_acme"));
355 assert_eq!(store.len(), 1);
356 }
357
358 #[test]
359 fn the_failure_budget_only_charges_failures() {
360 let limit = AuthFailureLimit::new(3, Duration::from_secs(60));
361 assert!(!limit.is_exhausted());
362
363 for _ in 0..100 {
365 assert!(!limit.is_exhausted());
366 }
367
368 limit.record_failure();
369 limit.record_failure();
370 assert!(!limit.is_exhausted(), "still within budget");
371 limit.record_failure();
372 assert!(limit.is_exhausted(), "the budget is spent");
373 }
374
375 #[test]
376 fn the_failure_budget_recovers_after_its_window() {
377 let limit = AuthFailureLimit::new(1, Duration::from_millis(50));
378 limit.record_failure();
379 assert!(limit.is_exhausted());
380
381 std::thread::sleep(Duration::from_millis(80));
382 assert!(
383 !limit.is_exhausted(),
384 "a new window must restore the budget"
385 );
386 }
387
388 #[test]
389 fn stores_only_hashes_and_resolves_keys() {
390 let store = InMemoryTenantStore::new();
391 store.insert_unchecked("mcp_secret", Tenant::new("acme", "cus_acme"));
392 assert_eq!(store.len(), 1);
393 assert_eq!(store.authenticate("mcp_secret").unwrap().id, "acme");
394 assert!(store.authenticate("wrong").is_none());
395 assert_ne!(hash_api_key("mcp_secret"), "mcp_secret");
396 assert!(!format!("{store:?}").contains(&hash_api_key("mcp_secret")));
397 }
398}