use crate::{ErasureFuture, IdentityAccountDeleter, IdentityDeletionError};
use baukit_config::Secret;
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use std::{
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use url::Url;
#[derive(Clone, Debug)]
pub struct KeycloakDeletionConfig {
pub base_url: String,
pub realm: String,
pub client_id: String,
pub client_secret: Secret<String>,
pub allow_local_http: bool,
}
#[derive(Clone)]
pub struct KeycloakAccountDeleter {
client: Client,
base: Url,
config: KeycloakDeletionConfig,
token: Arc<Mutex<Option<CachedToken>>>,
}
struct CachedToken {
value: Secret<String>,
expiry: Instant,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
}
impl KeycloakAccountDeleter {
pub fn new(config: KeycloakDeletionConfig) -> Result<Self, IdentityDeletionError> {
let base = Url::parse(&config.base_url).map_err(|_| IdentityDeletionError::Permanent)?;
if !(base.scheme() == "https" || (base.scheme() == "http" && config.allow_local_http))
|| base.host_str().is_none()
|| !base.username().is_empty()
|| base.password().is_some()
|| base.query().is_some()
|| base.fragment().is_some()
|| config.realm.is_empty()
|| config.client_id.is_empty()
|| config.client_secret.expose().is_empty()
{
return Err(IdentityDeletionError::Permanent);
}
let client = Client::builder()
.no_proxy()
.connect_timeout(Duration::from_secs(2))
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| IdentityDeletionError::Permanent)?;
Ok(Self {
client,
base,
config,
token: Arc::new(Mutex::new(None)),
})
}
fn url(&self, parts: &[&str]) -> Result<Url, IdentityDeletionError> {
let mut url = self.base.clone();
url.path_segments_mut()
.map_err(|_| IdentityDeletionError::Permanent)?
.pop_if_empty()
.extend(parts);
Ok(url)
}
async fn token(&self) -> Result<String, IdentityDeletionError> {
let mut cached = self.token.lock().await;
if let Some(token) = &*cached
&& Instant::now() < token.expiry
{
return Ok(token.value.expose().clone());
}
let token: TokenResponse = self
.client
.post(self.url(&[
"realms",
&self.config.realm,
"protocol",
"openid-connect",
"token",
])?)
.form(&[
("grant_type", "client_credentials"),
("client_id", self.config.client_id.as_str()),
("client_secret", self.config.client_secret.expose().as_str()),
])
.send()
.await
.map_err(|_| IdentityDeletionError::Retryable)?
.error_for_status()
.map_err(|_| IdentityDeletionError::Retryable)?
.json()
.await
.map_err(|_| IdentityDeletionError::Retryable)?;
if token.access_token.is_empty() {
return Err(IdentityDeletionError::Retryable);
}
let expiry = Instant::now() + Duration::from_secs(token.expires_in.saturating_sub(30));
*cached = Some(CachedToken {
value: Secret::new(token.access_token.clone()),
expiry,
});
Ok(token.access_token)
}
}
impl IdentityAccountDeleter for KeycloakAccountDeleter {
fn delete_account<'a>(
&'a self,
subject: &'a str,
) -> ErasureFuture<'a, Result<(), IdentityDeletionError>> {
Box::pin(async move {
if subject.is_empty() {
return Err(IdentityDeletionError::Permanent);
}
let response = self
.client
.delete(self.url(&["admin", "realms", &self.config.realm, "users", subject])?)
.bearer_auth(self.token().await?)
.send()
.await
.map_err(|_| IdentityDeletionError::Retryable)?;
let status = response.status();
if status.is_success() || status == StatusCode::NOT_FOUND {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
*self.token.lock().await = None;
return Err(IdentityDeletionError::Retryable);
}
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
Err(IdentityDeletionError::Retryable)
} else {
Err(IdentityDeletionError::Permanent)
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requires_https_and_redacts_secret() {
let mut config = KeycloakDeletionConfig {
base_url: "http://localhost".into(),
realm: "test".into(),
client_id: "backend".into(),
client_secret: Secret::new("secret".into()),
allow_local_http: false,
};
assert!(KeycloakAccountDeleter::new(config.clone()).is_err());
config.allow_local_http = true;
assert!(KeycloakAccountDeleter::new(config.clone()).is_ok());
assert!(!format!("{config:?}").contains("\"secret\""));
config.base_url = "https://user:pass@example.com".into();
assert!(KeycloakAccountDeleter::new(config).is_err());
}
}