use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use mcp_usage_core::PriceBook;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tenant {
pub id: String,
pub billing_customer_id: String,
pub prices: PriceBook,
}
impl Tenant {
#[must_use]
pub fn new(id: impl Into<String>, billing_customer_id: impl Into<String>) -> Self {
Self {
id: id.into(),
billing_customer_id: billing_customer_id.into(),
prices: PriceBook::default(),
}
}
#[must_use]
pub fn with_prices(mut self, prices: PriceBook) -> Self {
self.prices = prices;
self
}
}
pub trait TenantStore: Send + Sync {
fn authenticate(&self, api_key: &str) -> Option<Arc<Tenant>>;
}
#[must_use]
pub fn hash_api_key(api_key: &str) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let digest = Sha256::digest(api_key.as_bytes());
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
encoded
}
#[derive(Debug)]
pub(crate) struct AuthFailureLimit {
max_failures: u64,
window: Duration,
state: Mutex<FailureWindow>,
}
#[derive(Debug)]
struct FailureWindow {
started: Instant,
failures: u64,
}
impl AuthFailureLimit {
pub(crate) fn new(max_failures: u64, window: Duration) -> Self {
Self {
max_failures,
window,
state: Mutex::new(FailureWindow {
started: Instant::now(),
failures: 0,
}),
}
}
pub(crate) fn is_exhausted(&self) -> bool {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::expire(&mut state, self.window);
state.failures >= self.max_failures
}
pub(crate) fn record_failure(&self) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::expire(&mut state, self.window);
state.failures = state.failures.saturating_add(1);
}
fn expire(state: &mut FailureWindow, window: Duration) {
if state.started.elapsed() >= window {
state.started = Instant::now();
state.failures = 0;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeakApiKey {
TooShort,
TooFewDistinctSymbols,
TooPredictable,
}
impl std::fmt::Display for WeakApiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::TooShort => "API key is shorter than 24 bytes",
Self::TooFewDistinctSymbols => "API key uses fewer than 10 distinct characters",
Self::TooPredictable => "API key symbol distribution carries too little entropy",
})
}
}
impl std::error::Error for WeakApiKey {}
pub const MIN_API_KEY_BYTES: usize = 24;
const MIN_DISTINCT_SYMBOLS: usize = 10;
const MIN_BITS_PER_SYMBOL: f64 = 3.0;
pub fn validate_api_key_strength(api_key: &str) -> Result<(), WeakApiKey> {
let bytes = api_key.as_bytes();
if bytes.len() < MIN_API_KEY_BYTES {
return Err(WeakApiKey::TooShort);
}
let mut counts = [0usize; 256];
for &byte in bytes {
counts[usize::from(byte)] += 1;
}
let distinct = counts.iter().filter(|count| **count > 0).count();
if distinct < MIN_DISTINCT_SYMBOLS {
return Err(WeakApiKey::TooFewDistinctSymbols);
}
#[expect(
clippy::cast_precision_loss,
reason = "key lengths are far below the f64 integer precision limit"
)]
let total = bytes.len() as f64;
let bits_per_symbol: f64 = counts
.iter()
.filter(|count| **count > 0)
.map(|count| {
#[expect(
clippy::cast_precision_loss,
reason = "counts are bounded by the key length"
)]
let probability = *count as f64 / total;
-probability * probability.log2()
})
.sum();
if bits_per_symbol < MIN_BITS_PER_SYMBOL {
return Err(WeakApiKey::TooPredictable);
}
Ok(())
}
#[derive(Clone, Default)]
pub struct InMemoryTenantStore {
by_key_hash: Arc<RwLock<HashMap<String, Arc<Tenant>>>>,
}
impl std::fmt::Debug for InMemoryTenantStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InMemoryTenantStore")
.field("configured_keys", &self.len())
.finish_non_exhaustive()
}
}
impl InMemoryTenantStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, api_key: &str, tenant: Tenant) -> Result<(), WeakApiKey> {
validate_api_key_strength(api_key)?;
self.insert_unchecked(api_key, tenant);
Ok(())
}
pub fn insert_unchecked(&self, api_key: &str, tenant: Tenant) {
let mut tenants = self
.by_key_hash
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
tenants.insert(hash_api_key(api_key), Arc::new(tenant));
}
#[must_use]
pub fn len(&self) -> usize {
self.by_key_hash
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl TenantStore for InMemoryTenantStore {
fn authenticate(&self, api_key: &str) -> Option<Arc<Tenant>> {
self.by_key_hash
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&hash_api_key(api_key))
.cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn realistic_generated_keys_are_accepted() {
for key in [
"kZ2mQx7vR4tYbN8pL1sW3eH6jA5cF0uD9gK2nM4oP7Q=",
"9f3a1c7e5b2d8046af91c3e57d0b2a64",
"mcp_sk_live_7Hq2vXm9RtZp4LbN6wCk3JdY",
] {
assert_eq!(validate_api_key_strength(key), Ok(()), "rejected {key}");
}
}
#[test]
fn obviously_weak_keys_are_refused() {
for (key, expected) in [
("secret", WeakApiKey::TooShort),
("development-key", WeakApiKey::TooShort),
("password123", WeakApiKey::TooShort),
(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
WeakApiKey::TooFewDistinctSymbols,
),
(
"abababababababababababababababab",
WeakApiKey::TooFewDistinctSymbols,
),
(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabcdefghij",
WeakApiKey::TooPredictable,
),
] {
assert_eq!(
validate_api_key_strength(key),
Err(expected),
"accepted {key}"
);
}
}
#[test]
fn the_validating_insert_refuses_to_store_a_weak_key() {
let store = InMemoryTenantStore::new();
assert_eq!(
store.insert("development-key", Tenant::new("acme", "cus_acme")),
Err(WeakApiKey::TooShort)
);
assert!(store.is_empty(), "a refused key must not be stored");
assert!(store.authenticate("development-key").is_none());
store.insert_unchecked("development-key", Tenant::new("acme", "cus_acme"));
assert_eq!(store.len(), 1);
}
#[test]
fn the_failure_budget_only_charges_failures() {
let limit = AuthFailureLimit::new(3, Duration::from_secs(60));
assert!(!limit.is_exhausted());
for _ in 0..100 {
assert!(!limit.is_exhausted());
}
limit.record_failure();
limit.record_failure();
assert!(!limit.is_exhausted(), "still within budget");
limit.record_failure();
assert!(limit.is_exhausted(), "the budget is spent");
}
#[test]
fn the_failure_budget_recovers_after_its_window() {
let limit = AuthFailureLimit::new(1, Duration::from_millis(50));
limit.record_failure();
assert!(limit.is_exhausted());
std::thread::sleep(Duration::from_millis(80));
assert!(
!limit.is_exhausted(),
"a new window must restore the budget"
);
}
#[test]
fn stores_only_hashes_and_resolves_keys() {
let store = InMemoryTenantStore::new();
store.insert_unchecked("mcp_secret", Tenant::new("acme", "cus_acme"));
assert_eq!(store.len(), 1);
assert_eq!(store.authenticate("mcp_secret").unwrap().id, "acme");
assert!(store.authenticate("wrong").is_none());
assert_ne!(hash_api_key("mcp_secret"), "mcp_secret");
assert!(!format!("{store:?}").contains(&hash_api_key("mcp_secret")));
}
}