use std::collections::HashMap;
use std::error::Error as StdError;
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use std::time::{Duration, Instant};
use http::HeaderMap;
use super::{Access, Config, EMAIL_HEADER, HttpKeyFetcher, presents_access};
use crate::types::CloudflareConfig;
const WARN_INTERVAL: Duration = Duration::from_secs(60);
pub(crate) struct Provider {
http: OnceLock<reqwest::Client>,
state: RwLock<State>,
warned: Mutex<HashMap<String, Instant>>,
}
#[derive(Default)]
struct State {
config: Option<CloudflareConfig>,
access: Option<Arc<Access>>,
}
impl Provider {
pub(crate) fn new(config: Option<CloudflareConfig>) -> Self {
let provider = Self {
http: OnceLock::new(),
state: RwLock::default(),
warned: Mutex::default(),
};
if let Some(config) = config {
let mut state = provider
.state
.write()
.unwrap_or_else(PoisonError::into_inner);
provider.apply(&mut state, Some(config).filter(|c| c.enabled));
}
provider
}
pub(crate) fn set(&self, config: Option<CloudflareConfig>) {
let config = config.filter(|c| c.enabled);
let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
if state.config != config {
self.apply(&mut state, config);
}
}
fn apply(&self, state: &mut State, config: Option<CloudflareConfig>) {
log_settings(config.as_ref());
state.access = config.as_ref().map(|config| {
let http = self.http.get_or_init(reqwest::Client::new).clone();
Arc::new(Access::with_fetcher(
Config {
team_domain: config.team_domain.trim().to_owned(),
audience: config.audience.trim().to_owned(),
trust_email_header: config.trust_email_header,
verify_jwt: config.verify_jwt,
},
HttpKeyFetcher::new(http),
))
});
state.config = config;
}
pub(crate) async fn email(&self, headers: &HeaderMap) -> Option<String> {
let access = self
.state
.read()
.unwrap_or_else(PoisonError::into_inner)
.access
.clone();
let presented = presents_access(headers);
let Some(access) = access else {
if presented {
self.warn("Cloudflare Access sign-in is disabled, so the Access credentials on this request were ignored".to_owned());
}
return None;
};
match access.email(headers).await {
Ok(email) => Some(email),
Err(error) if presented => {
self.warn(format!(
"Cloudflare Access sign-in failed: {}",
chain(&error)
));
None
}
Err(error) => {
tracing::debug!(%error, "Cloudflare Access did not identify the request");
None
}
}
}
pub(crate) fn warn(&self, message: String) {
let now = Instant::now();
let mut warned = self.warned.lock().unwrap_or_else(PoisonError::into_inner);
warned.retain(|_, at| now.duration_since(*at) < WARN_INTERVAL);
if warned.contains_key(&message) {
return;
}
tracing::warn!("{message}");
warned.insert(message, now);
}
#[cfg(test)]
pub(crate) fn warnings(&self) -> Vec<String> {
let warned = self.warned.lock().unwrap_or_else(PoisonError::into_inner);
warned.keys().cloned().collect()
}
}
fn log_settings(config: Option<&CloudflareConfig>) {
let Some(config) = config else {
tracing::info!("Cloudflare Access sign-in is disabled");
return;
};
if !config.verify_jwt {
if config.trust_email_header {
tracing::warn!(
"SECURITY: Cloudflare Access sign-in trusts the {EMAIL_HEADER} header without verifying the Access token, so anyone who can reach this server without going through Cloudflare can sign in as any user by sending that header. Turn on Verify token unless the server is only reachable through Cloudflare."
);
} else {
tracing::warn!(
"Cloudflare Access sign-in is enabled, but neither Verify token nor Trust email header is, so no one can sign in through it"
);
}
return;
}
if config.team_domain.trim().is_empty() || config.audience.trim().is_empty() {
tracing::warn!(
"Cloudflare Access sign-in is enabled, but its team domain or audience is empty, so no one can sign in through it"
);
return;
}
tracing::info!(
team_domain = config.team_domain.trim(),
verify_jwt = config.verify_jwt,
trust_email_header = config.trust_email_header,
"Cloudflare Access sign-in is enabled"
);
}
fn chain(error: &dyn StdError) -> String {
let mut out = error.to_string();
let mut source = error.source();
while let Some(error) = source {
out.push_str(": ");
out.push_str(&error.to_string());
source = error.source();
}
out
}