Skip to main content

firstpass_proxy/
tenant_auth.rs

1//! Multi-tenant API-key authentication (ADR 0004 §D1) — the hosted plane's front door.
2//!
3//! **EXPERIMENTAL / pre-external-review (ADR 0004 §D7).** This is the *foundation* for tenant
4//! isolation; it has NOT yet had the independent, non-author security review the ADR requires.
5//! Do not rely on it as a hard isolation boundary for real, mutually-distrusting tenants until
6//! that review lands. It is default-OFF (`FIRSTPASS_REQUIRE_AUTH=false`): with auth disabled the
7//! single-operator path is unchanged and this module only stamps the static default tenant.
8//!
9//! Keys are verified against **Argon2id** password hashes via the crate's own constant-time
10//! [`PasswordVerifier`] path (ADR 0004 §D1) — we never roll our own comparison. A missing or
11//! invalid key returns `401` with an opaque body, so there is no "unknown tenant" oracle.
12
13use std::collections::HashMap;
14
15use argon2::Argon2;
16use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
17use axum::extract::{Request, State};
18use axum::http::{HeaderMap, header};
19use axum::middleware::Next;
20use axum::response::{IntoResponse, Response};
21
22use crate::error::ProxyError;
23use crate::proxy::AppState;
24
25/// Prefix for the `Authorization: Bearer <key>` header.
26const BEARER_PREFIX: &str = "Bearer ";
27/// Alternative header carrying the raw tenant API key.
28const KEY_HEADER: &str = "x-firstpass-key";
29
30/// A resolved tenant identity, injected into request extensions by [`auth_middleware`].
31///
32/// This is the *only* source of a request's tenant: it comes either from a verified API key
33/// (auth on) or the static default (auth off) — never from anything in the request body.
34#[derive(Clone, Debug)]
35pub struct TenantId(pub String);
36
37/// Argon2id-hashed per-tenant API keys, keyed by tenant id.
38///
39/// A presented key has the form `<tenant_id>.<secret>`: the (non-secret) tenant id names which
40/// hash to check, so verification is O(1) — exactly one Argon2 verify — rather than one per tenant.
41///
42/// `Debug` deliberately redacts every hash — they are password-equivalent secrets — revealing
43/// only the configured tenant ids so a `ProxyConfig` dump can never leak credential material.
44#[derive(Clone, Default)]
45pub struct TenantKeys {
46    /// `tenant_id` -> PHC-encoded Argon2id hash of that tenant's secret.
47    hashes: HashMap<String, String>,
48    /// A valid Argon2 hash of a throwaway secret, verified against on the unknown-tenant path so an
49    /// invalid key takes the same time whether or not the named tenant exists — no existence oracle.
50    decoy: String,
51}
52
53impl std::fmt::Debug for TenantKeys {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("TenantKeys")
56            .field("tenants", &self.hashes.keys().collect::<Vec<_>>())
57            .field("hashes", &"<redacted>")
58            .finish()
59    }
60}
61
62/// Errors building [`TenantKeys`] or hashing a key.
63#[derive(Debug, thiserror::Error)]
64pub enum AuthConfigError {
65    /// The tenant-keys JSON did not parse into `{ tenant_id: hash }`.
66    #[error("tenant keys JSON is invalid: {0}")]
67    Json(#[from] serde_json::Error),
68    /// A configured hash is not a valid Argon2 PHC string (fail closed at startup, not per-request).
69    #[error("tenant {tenant:?} has an invalid Argon2 hash")]
70    BadHash {
71        /// The offending tenant id (never the hash itself).
72        tenant: String,
73    },
74    /// The Argon2 hasher failed while provisioning a new key.
75    #[error("argon2 hashing failed")]
76    Hash,
77}
78
79impl TenantKeys {
80    /// Parse from a JSON object `{ "<tenant_id>": "<argon2id-phc-hash>", ... }`.
81    ///
82    /// Every hash is validated up front so a malformed roster fails at startup rather than
83    /// silently rejecting every request at runtime.
84    ///
85    /// # Errors
86    /// [`AuthConfigError::Json`] if the JSON is malformed; [`AuthConfigError::BadHash`] if any
87    /// value is not a valid Argon2 PHC hash string.
88    pub fn from_json(s: &str) -> Result<Self, AuthConfigError> {
89        let hashes: HashMap<String, String> = serde_json::from_str(s)?;
90        for (tenant, hash) in &hashes {
91            PasswordHash::new(hash).map_err(|_| AuthConfigError::BadHash {
92                tenant: tenant.clone(),
93            })?;
94        }
95        // Decoy hash for the unknown-tenant path (equalizes verify time). Computed once at startup.
96        let decoy = Self::hash_key("firstpass-unknown-tenant-decoy")?;
97        Ok(Self { hashes, decoy })
98    }
99
100    /// Whether any tenant keys are configured.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.hashes.is_empty()
104    }
105
106    /// Verify a presented `<tenant_id>.<secret>` key, returning the owning tenant on a match. The
107    /// tenant id (before the first `.`, not itself secret) names which hash to check, so this does
108    /// **exactly one** Argon2 verify — O(1), not O(tenants) — using the crate's constant-time
109    /// [`PasswordVerifier`], never a plain `==`.
110    ///
111    /// An unknown tenant id (or a malformed key) still performs one Argon2 verify against a decoy
112    /// hash, so an invalid key takes the same time whether or not the named tenant exists — this
113    /// closes both the CPU-amplification DoS and the tenant-existence timing oracle. Tenant ids must
114    /// not contain `.` (they are simple identifiers; the first `.` splits id from secret).
115    #[must_use]
116    pub fn verify(&self, presented_key: &str) -> Option<TenantId> {
117        let argon = Argon2::default();
118        let (tenant_id, secret) = presented_key.split_once('.').unwrap_or(("", presented_key));
119        if let Some(hash) = self.hashes.get(tenant_id) {
120            if let Ok(parsed) = PasswordHash::new(hash)
121                && argon.verify_password(secret.as_bytes(), &parsed).is_ok()
122            {
123                return Some(TenantId(tenant_id.to_owned()));
124            }
125            return None;
126        }
127        // Unknown tenant: burn one verify against the decoy to equalize timing, then fail.
128        if let Ok(decoy) = PasswordHash::new(&self.decoy) {
129            let _ = argon.verify_password(secret.as_bytes(), &decoy);
130        }
131        None
132    }
133
134    /// Hash a fresh secret into a PHC-encoded Argon2id string for provisioning a tenant key.
135    /// Operators store the returned hash; the plaintext key is shown to the tenant once and is
136    /// never persisted here.
137    ///
138    /// # Errors
139    /// [`AuthConfigError::Hash`] if the Argon2 hasher fails.
140    pub fn hash_key(secret: &str) -> Result<String, AuthConfigError> {
141        let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
142        Argon2::default()
143            .hash_password(secret.as_bytes(), &salt)
144            .map(|h| h.to_string())
145            .map_err(|_| AuthConfigError::Hash)
146    }
147}
148
149/// Axum middleware: resolve the tenant for a request and inject a [`TenantId`] into extensions.
150///
151/// - `require_auth = false` (default): no auth check; injects the static default tenant so every
152///   downstream handler reads a uniform identity. Single-operator behavior is unchanged.
153/// - `require_auth = true`: reads the key from `Authorization: Bearer <key>` (or `x-firstpass-key`),
154///   verifies it against the configured tenant hashes, and injects the resolved tenant. A missing
155///   or invalid key returns `401` with an opaque body — no "unknown tenant" oracle.
156pub async fn auth_middleware(
157    State(state): State<AppState>,
158    mut req: Request,
159    next: Next,
160) -> Response {
161    if !state.config.require_auth {
162        req.extensions_mut()
163            .insert(TenantId(state.config.tenant_id.clone()));
164        return next.run(req).await;
165    }
166    match extract_key(req.headers()).and_then(|k| state.config.tenant_keys.verify(&k)) {
167        Some(tenant) => {
168            req.extensions_mut().insert(tenant);
169            next.run(req).await
170        }
171        None => ProxyError::Unauthorized.into_response(),
172    }
173}
174
175/// Read the tenant API key from `Authorization: Bearer <key>` or the `x-firstpass-key` header.
176fn extract_key(headers: &HeaderMap) -> Option<String> {
177    let bearer = headers
178        .get(header::AUTHORIZATION)
179        .and_then(|v| v.to_str().ok())
180        .and_then(|v| v.strip_prefix(BEARER_PREFIX))
181        .map(str::trim)
182        .filter(|s| !s.is_empty());
183    if let Some(key) = bearer {
184        return Some(key.to_owned());
185    }
186    headers
187        .get(KEY_HEADER)
188        .and_then(|v| v.to_str().ok())
189        .map(str::trim)
190        .filter(|s| !s.is_empty())
191        .map(str::to_owned)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn argon2_hash_round_trips_and_rejects_wrong_key() {
200        let hash = TenantKeys::hash_key("s3cret-key").unwrap();
201        let json = format!("{{\"acme\": {hash:?}}}");
202        let keys = TenantKeys::from_json(&json).unwrap();
203
204        // Correct `<tenant>.<secret>` key resolves to its tenant.
205        assert_eq!(
206            keys.verify("acme.s3cret-key").map(|t| t.0).as_deref(),
207            Some("acme")
208        );
209        // Right tenant, wrong secret → nothing.
210        assert!(keys.verify("acme.wrong-key").is_none());
211        // Unknown tenant → nothing (and it still burns a decoy verify — no existence oracle).
212        assert!(keys.verify("ghost.s3cret-key").is_none());
213        // Malformed (no `.`) and empty keys never match.
214        assert!(keys.verify("s3cret-key").is_none());
215        assert!(keys.verify("").is_none());
216    }
217
218    #[test]
219    fn two_tenants_resolve_to_their_own_identity() {
220        let a = TenantKeys::hash_key("key-a").unwrap();
221        let b = TenantKeys::hash_key("key-b").unwrap();
222        let json = format!("{{\"tenant-a\": {a:?}, \"tenant-b\": {b:?}}}");
223        let keys = TenantKeys::from_json(&json).unwrap();
224
225        assert_eq!(
226            keys.verify("tenant-a.key-a").map(|t| t.0).as_deref(),
227            Some("tenant-a")
228        );
229        assert_eq!(
230            keys.verify("tenant-b.key-b").map(|t| t.0).as_deref(),
231            Some("tenant-b")
232        );
233        // Tenant A's id with tenant B's secret is rejected — you can't cross tenants.
234        assert!(keys.verify("tenant-a.key-b").is_none());
235        // A secret for no configured tenant is rejected.
236        assert!(keys.verify("tenant-c.key-c").is_none());
237    }
238
239    #[test]
240    fn invalid_hash_in_config_is_rejected_at_build_time() {
241        let json = r#"{"acme": "not-a-real-argon2-hash"}"#;
242        assert!(matches!(
243            TenantKeys::from_json(json),
244            Err(AuthConfigError::BadHash { tenant }) if tenant == "acme"
245        ));
246    }
247
248    #[test]
249    fn debug_redacts_hashes() {
250        let hash = TenantKeys::hash_key("top-secret").unwrap();
251        let json = format!("{{\"acme\": {hash:?}}}");
252        let keys = TenantKeys::from_json(&json).unwrap();
253        let dbg = format!("{keys:?}");
254        assert!(dbg.contains("acme"), "tenant id is fine to show");
255        assert!(dbg.contains("<redacted>"), "hashes must be redacted");
256        assert!(
257            !dbg.contains("$argon2"),
258            "no hash material may leak in Debug"
259        );
260    }
261
262    #[test]
263    fn extract_key_reads_bearer_and_custom_header() {
264        let mut h = HeaderMap::new();
265        h.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap());
266        assert_eq!(extract_key(&h).as_deref(), Some("abc123"));
267
268        let mut h2 = HeaderMap::new();
269        h2.insert(KEY_HEADER, "xyz789".parse().unwrap());
270        assert_eq!(extract_key(&h2).as_deref(), Some("xyz789"));
271
272        // No key header at all.
273        assert!(extract_key(&HeaderMap::new()).is_none());
274        // Empty bearer token is not a key.
275        let mut h3 = HeaderMap::new();
276        h3.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap());
277        assert!(extract_key(&h3).is_none());
278    }
279}