use std::collections::HashMap;
use argon2::Argon2;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use axum::extract::{Request, State};
use axum::http::{HeaderMap, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use crate::error::ProxyError;
use crate::proxy::AppState;
const BEARER_PREFIX: &str = "Bearer ";
const KEY_HEADER: &str = "x-firstpass-key";
#[derive(Clone, Debug)]
pub struct TenantId(pub String);
#[derive(Clone, Default)]
pub struct TenantKeys {
hashes: HashMap<String, String>,
decoy: String,
}
impl std::fmt::Debug for TenantKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TenantKeys")
.field("tenants", &self.hashes.keys().collect::<Vec<_>>())
.field("hashes", &"<redacted>")
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthConfigError {
#[error("tenant keys JSON is invalid: {0}")]
Json(#[from] serde_json::Error),
#[error("tenant {tenant:?} has an invalid Argon2 hash")]
BadHash {
tenant: String,
},
#[error("argon2 hashing failed")]
Hash,
}
impl TenantKeys {
pub fn from_json(s: &str) -> Result<Self, AuthConfigError> {
let hashes: HashMap<String, String> = serde_json::from_str(s)?;
for (tenant, hash) in &hashes {
PasswordHash::new(hash).map_err(|_| AuthConfigError::BadHash {
tenant: tenant.clone(),
})?;
}
let decoy = Self::hash_key("firstpass-unknown-tenant-decoy")?;
Ok(Self { hashes, decoy })
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.hashes.is_empty()
}
#[must_use]
pub fn verify(&self, presented_key: &str) -> Option<TenantId> {
let argon = Argon2::default();
let (tenant_id, secret) = presented_key.split_once('.').unwrap_or(("", presented_key));
if let Some(hash) = self.hashes.get(tenant_id) {
if let Ok(parsed) = PasswordHash::new(hash)
&& argon.verify_password(secret.as_bytes(), &parsed).is_ok()
{
return Some(TenantId(tenant_id.to_owned()));
}
return None;
}
if let Ok(decoy) = PasswordHash::new(&self.decoy) {
let _ = argon.verify_password(secret.as_bytes(), &decoy);
}
None
}
pub fn hash_key(secret: &str) -> Result<String, AuthConfigError> {
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|_| AuthConfigError::Hash)
}
}
pub async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Response {
if !state.config.require_auth {
req.extensions_mut()
.insert(TenantId(state.config.tenant_id.clone()));
return next.run(req).await;
}
match extract_key(req.headers()).and_then(|k| state.config.tenant_keys.verify(&k)) {
Some(tenant) => {
req.extensions_mut().insert(tenant);
next.run(req).await
}
None => ProxyError::Unauthorized.into_response(),
}
}
fn extract_key(headers: &HeaderMap) -> Option<String> {
let bearer = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix(BEARER_PREFIX))
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(key) = bearer {
return Some(key.to_owned());
}
headers
.get(KEY_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argon2_hash_round_trips_and_rejects_wrong_key() {
let hash = TenantKeys::hash_key("s3cret-key").unwrap();
let json = format!("{{\"acme\": {hash:?}}}");
let keys = TenantKeys::from_json(&json).unwrap();
assert_eq!(
keys.verify("acme.s3cret-key").map(|t| t.0).as_deref(),
Some("acme")
);
assert!(keys.verify("acme.wrong-key").is_none());
assert!(keys.verify("ghost.s3cret-key").is_none());
assert!(keys.verify("s3cret-key").is_none());
assert!(keys.verify("").is_none());
}
#[test]
fn two_tenants_resolve_to_their_own_identity() {
let a = TenantKeys::hash_key("key-a").unwrap();
let b = TenantKeys::hash_key("key-b").unwrap();
let json = format!("{{\"tenant-a\": {a:?}, \"tenant-b\": {b:?}}}");
let keys = TenantKeys::from_json(&json).unwrap();
assert_eq!(
keys.verify("tenant-a.key-a").map(|t| t.0).as_deref(),
Some("tenant-a")
);
assert_eq!(
keys.verify("tenant-b.key-b").map(|t| t.0).as_deref(),
Some("tenant-b")
);
assert!(keys.verify("tenant-a.key-b").is_none());
assert!(keys.verify("tenant-c.key-c").is_none());
}
#[test]
fn invalid_hash_in_config_is_rejected_at_build_time() {
let json = r#"{"acme": "not-a-real-argon2-hash"}"#;
assert!(matches!(
TenantKeys::from_json(json),
Err(AuthConfigError::BadHash { tenant }) if tenant == "acme"
));
}
#[test]
fn debug_redacts_hashes() {
let hash = TenantKeys::hash_key("top-secret").unwrap();
let json = format!("{{\"acme\": {hash:?}}}");
let keys = TenantKeys::from_json(&json).unwrap();
let dbg = format!("{keys:?}");
assert!(dbg.contains("acme"), "tenant id is fine to show");
assert!(dbg.contains("<redacted>"), "hashes must be redacted");
assert!(
!dbg.contains("$argon2"),
"no hash material may leak in Debug"
);
}
#[test]
fn extract_key_reads_bearer_and_custom_header() {
let mut h = HeaderMap::new();
h.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap());
assert_eq!(extract_key(&h).as_deref(), Some("abc123"));
let mut h2 = HeaderMap::new();
h2.insert(KEY_HEADER, "xyz789".parse().unwrap());
assert_eq!(extract_key(&h2).as_deref(), Some("xyz789"));
assert!(extract_key(&HeaderMap::new()).is_none());
let mut h3 = HeaderMap::new();
h3.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap());
assert!(extract_key(&h3).is_none());
}
}