use std::borrow::ToOwned;
use std::collections::HashMap;
use std::error::Error as StdError;
use std::string::String;
use std::string::ToString;
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use std::time::{Duration, Instant};
#[cfg(test)]
use std::vec::Vec;
use http::HeaderMap;
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
use super::{Access, Config, EMAIL_HEADER, HttpKeyFetcher, presents_access};
use crate::store::{DynStore, StoreError};
use crate::types::CloudflareConfig;
pub(crate) const SETTINGS_KEY: &str = "cloudflare";
const WARN_INTERVAL: Duration = Duration::from_secs(60);
pub(crate) struct Provider {
initial: Option<CloudflareConfig>,
loaded: OnceCell<()>,
saving: AsyncMutex<()>,
http: OnceLock<reqwest::Client>,
state: RwLock<State>,
warned: Mutex<HashMap<String, Instant>>,
}
#[derive(Default)]
struct State {
settings: Option<CloudflareConfig>,
config: Option<CloudflareConfig>,
access: Option<Arc<Access>>,
}
impl Provider {
pub(crate) fn new(initial: Option<CloudflareConfig>) -> Self {
Self {
initial,
loaded: OnceCell::new(),
saving: AsyncMutex::new(()),
http: OnceLock::new(),
state: RwLock::default(),
warned: Mutex::default(),
}
}
pub(crate) async fn load(&self, store: &dyn DynStore) -> Result<(), StoreError> {
self.loaded
.get_or_try_init(|| async {
let settings = match store.setting(SETTINGS_KEY).await? {
Some(stored) => match serde_json::from_str::<CloudflareConfig>(&stored) {
Ok(settings) => Some(settings),
Err(error) => {
tracing::error!(
"The stored Cloudflare Access settings are malformed, so the initial ones are used until an admin saves them again: {error}"
);
self.initial.clone()
}
},
None => self.initial.clone(),
};
let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
if let Some(settings) = settings {
self.apply(&mut state, settings);
}
Ok::<_, StoreError>(())
})
.await?;
Ok(())
}
pub(crate) fn settings(&self) -> Option<CloudflareConfig> {
self.state
.read()
.unwrap_or_else(PoisonError::into_inner)
.settings
.clone()
}
pub(crate) async fn save(
&self,
store: &dyn DynStore,
mut settings: CloudflareConfig,
) -> Result<(Option<CloudflareConfig>, CloudflareConfig), StoreError> {
settings.team_domain = settings.team_domain.trim().to_owned();
settings.audience = settings.audience.trim().to_owned();
let json = serde_json::to_string(&settings)
.map_err(|error| StoreError::new(format!("encoding the settings failed: {error}")))?;
let _saving = self.saving.lock().await;
self.load(store).await?;
store.set_setting(SETTINGS_KEY, &json).await?;
let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
let before = state.settings.clone();
self.apply(&mut state, settings.clone());
Ok((before, settings))
}
fn apply(&self, state: &mut State, settings: CloudflareConfig) {
let first = state.settings.is_none();
let config = Some(settings.clone()).filter(|c| c.enabled);
state.settings = Some(settings);
if !first && state.config == config {
return;
}
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
}