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    ///
44    /// Returning an [`Arc`] rather than an owned [`Tenant`] because this runs
45    /// once per request and a [`PriceBook`] is two `BTreeMap`s: handing back a
46    /// value meant deep-copying every priced tool name on every call, for data
47    /// the request only reads.
48    fn authenticate(&self, api_key: &str) -> Option<Arc<Tenant>>;
49}
50
51/// SHA-256 lookup hash for an API key.
52///
53/// API keys are high-entropy secrets, so a fast cryptographic lookup hash is
54/// appropriate; this is not a password hash.
55#[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/// Caps how fast credentials can be guessed, across the whole edge.
68///
69/// Only failures consume budget, so a caller presenting a valid key is never
70/// affected no matter how much guessing is happening alongside it. That is what
71/// makes a global counter safe here: exhausting it cannot lock out legitimate
72/// traffic, it can only turn a wrong key's `401` into a `429`.
73///
74/// The window is fixed rather than a sliding bucket, which admits a burst across
75/// a boundary. That is an acceptable trade for a defense whose purpose is to
76/// bound sustained throughput, and it keeps the accounting to two fields.
77///
78/// This is not a substitute for per-client limiting. The edge cannot see a
79/// trustworthy client identity: a source address belongs to the transport, and
80/// forwarding headers are attacker controlled. Per-address limits belong in the
81/// proxy or load balancer in front of this.
82#[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    /// Whether guessing should be refused before a credential is even read.
108    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    /// Charge one failed authentication against the current window.
118    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/// Why an API key was refused as too weak to be safe.
136///
137/// See [`validate_api_key_strength`] for what is and is not being claimed here.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum WeakApiKey {
140    /// Shorter than [`MIN_API_KEY_BYTES`].
141    TooShort,
142    /// Built from too small an alphabet to carry meaningful entropy.
143    TooFewDistinctSymbols,
144    /// Symbol distribution is too skewed, as in a long run of one character.
145    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
160/// Shortest API key accepted by [`validate_api_key_strength`].
161pub const MIN_API_KEY_BYTES: usize = 24;
162const MIN_DISTINCT_SYMBOLS: usize = 10;
163const MIN_BITS_PER_SYMBOL: f64 = 3.0;
164
165/// Reject API keys that are obviously too weak to be compared by digest.
166///
167/// Keys are looked up by SHA-256, which is a fast lookup hash and deliberately
168/// not a password hash. That is the right choice for a high-entropy secret and
169/// the wrong one for a guessable string: a low-entropy key is brute forceable
170/// online, and offline in moments if the digest table ever leaks.
171///
172/// This is a guardrail against obvious mistakes, not a certificate of strength.
173/// It measures length, alphabet size, and the Shannon entropy of the observed
174/// symbol distribution. It cannot see structure: a long repeating pattern drawn
175/// from a wide alphabet will pass. Generate keys from a CSPRNG rather than
176/// relying on this to grade a hand-written one.
177///
178/// # Errors
179///
180/// Returns the specific [`WeakApiKey`] reason the key was refused.
181pub 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    // Shannon entropy of the observed distribution, in bits per symbol.
197    #[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/// Mutable in-memory tenant table for development and embedded deployments.
221#[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    /// Construct an empty tenant table.
236    #[must_use]
237    pub fn new() -> Self {
238        Self::default()
239    }
240
241    /// Insert or replace a tenant under the presented plaintext API key.
242    ///
243    /// The key is checked by [`validate_api_key_strength`] first, because a
244    /// digest lookup only protects a secret that was hard to guess to begin
245    /// with. Use [`InMemoryTenantStore::insert_unchecked`] when the key has
246    /// already been validated elsewhere.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`WeakApiKey`] without inserting anything.
251    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    /// Insert or replace a tenant without checking the key's strength.
258    ///
259    /// For tests, for fixtures, and for deployments that validate keys at the
260    /// boundary where they are issued. Everything else should prefer
261    /// [`InMemoryTenantStore::insert`].
262    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    /// Number of configured keys.
271    #[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    /// Whether no keys are configured.
280    #[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            // 32 random bytes, base64. The shape `openssl rand -base64 32` gives.
304            "kZ2mQx7vR4tYbN8pL1sW3eH6jA5cF0uD9gK2nM4oP7Q=",
305            // 128-bit hex, a smaller alphabet but still ample entropy.
306            "9f3a1c7e5b2d8046af91c3e57d0b2a64",
307            // A prefixed provider-style key.
308            "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            // Long enough, but drawn from almost no alphabet.
321            (
322                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
323                WeakApiKey::TooFewDistinctSymbols,
324            ),
325            (
326                "abababababababababababababababab",
327                WeakApiKey::TooFewDistinctSymbols,
328            ),
329            // Wide alphabet, but one symbol dominates the distribution.
330            (
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        // The escape hatch exists for fixtures and for keys validated elsewhere.
354        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        // Successes never touch it, so valid traffic cannot exhaust the budget.
364        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}