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}