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<Tenant>;
44}
45
46#[must_use]
51pub fn hash_api_key(api_key: &str) -> String {
52 const HEX: &[u8; 16] = b"0123456789abcdef";
53 let digest = Sha256::digest(api_key.as_bytes());
54 let mut encoded = String::with_capacity(digest.len() * 2);
55 for byte in digest {
56 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
57 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
58 }
59 encoded
60}
61
62#[derive(Debug)]
78pub(crate) struct AuthFailureLimit {
79 max_failures: u64,
80 window: Duration,
81 state: Mutex<FailureWindow>,
82}
83
84#[derive(Debug)]
85struct FailureWindow {
86 started: Instant,
87 failures: u64,
88}
89
90impl AuthFailureLimit {
91 pub(crate) fn new(max_failures: u64, window: Duration) -> Self {
92 Self {
93 max_failures,
94 window,
95 state: Mutex::new(FailureWindow {
96 started: Instant::now(),
97 failures: 0,
98 }),
99 }
100 }
101
102 pub(crate) fn is_exhausted(&self) -> bool {
104 let mut state = self
105 .state
106 .lock()
107 .unwrap_or_else(std::sync::PoisonError::into_inner);
108 Self::expire(&mut state, self.window);
109 state.failures >= self.max_failures
110 }
111
112 pub(crate) fn record_failure(&self) {
114 let mut state = self
115 .state
116 .lock()
117 .unwrap_or_else(std::sync::PoisonError::into_inner);
118 Self::expire(&mut state, self.window);
119 state.failures = state.failures.saturating_add(1);
120 }
121
122 fn expire(state: &mut FailureWindow, window: Duration) {
123 if state.started.elapsed() >= window {
124 state.started = Instant::now();
125 state.failures = 0;
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum WeakApiKey {
135 TooShort,
137 TooFewDistinctSymbols,
139 TooPredictable,
141}
142
143impl std::fmt::Display for WeakApiKey {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.write_str(match self {
146 Self::TooShort => "API key is shorter than 24 bytes",
147 Self::TooFewDistinctSymbols => "API key uses fewer than 10 distinct characters",
148 Self::TooPredictable => "API key symbol distribution carries too little entropy",
149 })
150 }
151}
152
153impl std::error::Error for WeakApiKey {}
154
155pub const MIN_API_KEY_BYTES: usize = 24;
157const MIN_DISTINCT_SYMBOLS: usize = 10;
158const MIN_BITS_PER_SYMBOL: f64 = 3.0;
159
160pub fn validate_api_key_strength(api_key: &str) -> Result<(), WeakApiKey> {
177 let bytes = api_key.as_bytes();
178 if bytes.len() < MIN_API_KEY_BYTES {
179 return Err(WeakApiKey::TooShort);
180 }
181
182 let mut counts = [0usize; 256];
183 for &byte in bytes {
184 counts[usize::from(byte)] += 1;
185 }
186 let distinct = counts.iter().filter(|count| **count > 0).count();
187 if distinct < MIN_DISTINCT_SYMBOLS {
188 return Err(WeakApiKey::TooFewDistinctSymbols);
189 }
190
191 #[expect(
193 clippy::cast_precision_loss,
194 reason = "key lengths are far below the f64 integer precision limit"
195 )]
196 let total = bytes.len() as f64;
197 let bits_per_symbol: f64 = counts
198 .iter()
199 .filter(|count| **count > 0)
200 .map(|count| {
201 #[expect(
202 clippy::cast_precision_loss,
203 reason = "counts are bounded by the key length"
204 )]
205 let probability = *count as f64 / total;
206 -probability * probability.log2()
207 })
208 .sum();
209 if bits_per_symbol < MIN_BITS_PER_SYMBOL {
210 return Err(WeakApiKey::TooPredictable);
211 }
212 Ok(())
213}
214
215#[derive(Clone, Default)]
217pub struct InMemoryTenantStore {
218 by_key_hash: Arc<RwLock<HashMap<String, Tenant>>>,
219}
220
221impl std::fmt::Debug for InMemoryTenantStore {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 f.debug_struct("InMemoryTenantStore")
224 .field("configured_keys", &self.len())
225 .finish_non_exhaustive()
226 }
227}
228
229impl InMemoryTenantStore {
230 #[must_use]
232 pub fn new() -> Self {
233 Self::default()
234 }
235
236 pub fn insert(&self, api_key: &str, tenant: Tenant) -> Result<(), WeakApiKey> {
247 validate_api_key_strength(api_key)?;
248 self.insert_unchecked(api_key, tenant);
249 Ok(())
250 }
251
252 pub fn insert_unchecked(&self, api_key: &str, tenant: Tenant) {
258 let mut tenants = self
259 .by_key_hash
260 .write()
261 .unwrap_or_else(std::sync::PoisonError::into_inner);
262 tenants.insert(hash_api_key(api_key), tenant);
263 }
264
265 #[must_use]
267 pub fn len(&self) -> usize {
268 self.by_key_hash
269 .read()
270 .unwrap_or_else(std::sync::PoisonError::into_inner)
271 .len()
272 }
273
274 #[must_use]
276 pub fn is_empty(&self) -> bool {
277 self.len() == 0
278 }
279}
280
281impl TenantStore for InMemoryTenantStore {
282 fn authenticate(&self, api_key: &str) -> Option<Tenant> {
283 self.by_key_hash
284 .read()
285 .unwrap_or_else(std::sync::PoisonError::into_inner)
286 .get(&hash_api_key(api_key))
287 .cloned()
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn realistic_generated_keys_are_accepted() {
297 for key in [
298 "kZ2mQx7vR4tYbN8pL1sW3eH6jA5cF0uD9gK2nM4oP7Q=",
300 "9f3a1c7e5b2d8046af91c3e57d0b2a64",
302 "mcp_sk_live_7Hq2vXm9RtZp4LbN6wCk3JdY",
304 ] {
305 assert_eq!(validate_api_key_strength(key), Ok(()), "rejected {key}");
306 }
307 }
308
309 #[test]
310 fn obviously_weak_keys_are_refused() {
311 for (key, expected) in [
312 ("secret", WeakApiKey::TooShort),
313 ("development-key", WeakApiKey::TooShort),
314 ("password123", WeakApiKey::TooShort),
315 (
317 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
318 WeakApiKey::TooFewDistinctSymbols,
319 ),
320 (
321 "abababababababababababababababab",
322 WeakApiKey::TooFewDistinctSymbols,
323 ),
324 (
326 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabcdefghij",
327 WeakApiKey::TooPredictable,
328 ),
329 ] {
330 assert_eq!(
331 validate_api_key_strength(key),
332 Err(expected),
333 "accepted {key}"
334 );
335 }
336 }
337
338 #[test]
339 fn the_validating_insert_refuses_to_store_a_weak_key() {
340 let store = InMemoryTenantStore::new();
341 assert_eq!(
342 store.insert("development-key", Tenant::new("acme", "cus_acme")),
343 Err(WeakApiKey::TooShort)
344 );
345 assert!(store.is_empty(), "a refused key must not be stored");
346 assert!(store.authenticate("development-key").is_none());
347
348 store.insert_unchecked("development-key", Tenant::new("acme", "cus_acme"));
350 assert_eq!(store.len(), 1);
351 }
352
353 #[test]
354 fn the_failure_budget_only_charges_failures() {
355 let limit = AuthFailureLimit::new(3, Duration::from_secs(60));
356 assert!(!limit.is_exhausted());
357
358 for _ in 0..100 {
360 assert!(!limit.is_exhausted());
361 }
362
363 limit.record_failure();
364 limit.record_failure();
365 assert!(!limit.is_exhausted(), "still within budget");
366 limit.record_failure();
367 assert!(limit.is_exhausted(), "the budget is spent");
368 }
369
370 #[test]
371 fn the_failure_budget_recovers_after_its_window() {
372 let limit = AuthFailureLimit::new(1, Duration::from_millis(50));
373 limit.record_failure();
374 assert!(limit.is_exhausted());
375
376 std::thread::sleep(Duration::from_millis(80));
377 assert!(
378 !limit.is_exhausted(),
379 "a new window must restore the budget"
380 );
381 }
382
383 #[test]
384 fn stores_only_hashes_and_resolves_keys() {
385 let store = InMemoryTenantStore::new();
386 store.insert_unchecked("mcp_secret", Tenant::new("acme", "cus_acme"));
387 assert_eq!(store.len(), 1);
388 assert_eq!(store.authenticate("mcp_secret").unwrap().id, "acme");
389 assert!(store.authenticate("wrong").is_none());
390 assert_ne!(hash_api_key("mcp_secret"), "mcp_secret");
391 assert!(!format!("{store:?}").contains(&hash_api_key("mcp_secret")));
392 }
393}