Skip to main content

mcp_usage_tower/
auth.rs

1//! API-key authentication and per-tenant pricing.
2
3use 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/// Authenticated tenant configuration used by the edge.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Tenant {
13    /// Stable internal tenant identifier.
14    pub id: String,
15    /// Customer identifier understood by the billing exporter.
16    pub billing_customer_id: String,
17    /// Per-name and per-method prices for this tenant.
18    pub prices: PriceBook,
19}
20
21impl Tenant {
22    /// Construct a tenant with a flat one-unit price book.
23    #[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    /// Replace the tenant's price book.
33    #[must_use]
34    pub fn with_prices(mut self, prices: PriceBook) -> Self {
35        self.prices = prices;
36        self
37    }
38}
39
40/// Resolves a presented high-entropy API key without retaining its plaintext.
41pub trait TenantStore: Send + Sync {
42    /// Return the matching tenant, or `None` for an invalid key.
43    fn authenticate(&self, api_key: &str) -> Option<Tenant>;
44}
45
46/// SHA-256 lookup hash for an API key.
47///
48/// API keys are high-entropy secrets, so a fast cryptographic lookup hash is
49/// appropriate; this is not a password hash.
50#[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/// Caps how fast credentials can be guessed, across the whole edge.
63///
64/// Only failures consume budget, so a caller presenting a valid key is never
65/// affected no matter how much guessing is happening alongside it. That is what
66/// makes a global counter safe here: exhausting it cannot lock out legitimate
67/// traffic, it can only turn a wrong key's `401` into a `429`.
68///
69/// The window is fixed rather than a sliding bucket, which admits a burst across
70/// a boundary. That is an acceptable trade for a defense whose purpose is to
71/// bound sustained throughput, and it keeps the accounting to two fields.
72///
73/// This is not a substitute for per-client limiting. The edge cannot see a
74/// trustworthy client identity: a source address belongs to the transport, and
75/// forwarding headers are attacker controlled. Per-address limits belong in the
76/// proxy or load balancer in front of this.
77#[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    /// Whether guessing should be refused before a credential is even read.
103    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    /// Charge one failed authentication against the current window.
113    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/// Why an API key was refused as too weak to be safe.
131///
132/// See [`validate_api_key_strength`] for what is and is not being claimed here.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum WeakApiKey {
135    /// Shorter than [`MIN_API_KEY_BYTES`].
136    TooShort,
137    /// Built from too small an alphabet to carry meaningful entropy.
138    TooFewDistinctSymbols,
139    /// Symbol distribution is too skewed, as in a long run of one character.
140    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
155/// Shortest API key accepted by [`validate_api_key_strength`].
156pub const MIN_API_KEY_BYTES: usize = 24;
157const MIN_DISTINCT_SYMBOLS: usize = 10;
158const MIN_BITS_PER_SYMBOL: f64 = 3.0;
159
160/// Reject API keys that are obviously too weak to be compared by digest.
161///
162/// Keys are looked up by SHA-256, which is a fast lookup hash and deliberately
163/// not a password hash. That is the right choice for a high-entropy secret and
164/// the wrong one for a guessable string: a low-entropy key is brute forceable
165/// online, and offline in moments if the digest table ever leaks.
166///
167/// This is a guardrail against obvious mistakes, not a certificate of strength.
168/// It measures length, alphabet size, and the Shannon entropy of the observed
169/// symbol distribution. It cannot see structure: a long repeating pattern drawn
170/// from a wide alphabet will pass. Generate keys from a CSPRNG rather than
171/// relying on this to grade a hand-written one.
172///
173/// # Errors
174///
175/// Returns the specific [`WeakApiKey`] reason the key was refused.
176pub 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    // Shannon entropy of the observed distribution, in bits per symbol.
192    #[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/// Mutable in-memory tenant table for development and embedded deployments.
216#[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    /// Construct an empty tenant table.
231    #[must_use]
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Insert or replace a tenant under the presented plaintext API key.
237    ///
238    /// The key is checked by [`validate_api_key_strength`] first, because a
239    /// digest lookup only protects a secret that was hard to guess to begin
240    /// with. Use [`InMemoryTenantStore::insert_unchecked`] when the key has
241    /// already been validated elsewhere.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`WeakApiKey`] without inserting anything.
246    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    /// Insert or replace a tenant without checking the key's strength.
253    ///
254    /// For tests, for fixtures, and for deployments that validate keys at the
255    /// boundary where they are issued. Everything else should prefer
256    /// [`InMemoryTenantStore::insert`].
257    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    /// Number of configured keys.
266    #[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    /// Whether no keys are configured.
275    #[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            // 32 random bytes, base64. The shape `openssl rand -base64 32` gives.
299            "kZ2mQx7vR4tYbN8pL1sW3eH6jA5cF0uD9gK2nM4oP7Q=",
300            // 128-bit hex, a smaller alphabet but still ample entropy.
301            "9f3a1c7e5b2d8046af91c3e57d0b2a64",
302            // A prefixed provider-style key.
303            "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            // Long enough, but drawn from almost no alphabet.
316            (
317                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
318                WeakApiKey::TooFewDistinctSymbols,
319            ),
320            (
321                "abababababababababababababababab",
322                WeakApiKey::TooFewDistinctSymbols,
323            ),
324            // Wide alphabet, but one symbol dominates the distribution.
325            (
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        // The escape hatch exists for fixtures and for keys validated elsewhere.
349        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        // Successes never touch it, so valid traffic cannot exhaust the budget.
359        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}