use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use axum::response::{IntoResponse, Response};
use tracing::{debug, error, warn};
use issuerd_core::{
AdminEvent, EventId, IssuerdError, OperationType, Realm, RealmId, ResourceType, SessionId,
SessionLogoutNotifier, Storage, UserSession,
};
use issuerd_token::token_manager::TokenIssuer;
pub const CLIENT_ATTR_BACKCHANNEL_LOGOUT_URI: &str = "backchannel_logout_uri";
pub const CLIENT_ATTR_FRONTCHANNEL_LOGOUT_URI: &str = "frontchannel_logout_uri";
const BACKCHANNEL_TIMEOUT_SECS: u64 = 5;
pub struct BackchannelLogoutDispatcher {
storage: Arc<dyn Storage>,
token_manager: Arc<dyn TokenIssuer>,
http: reqwest::Client,
}
impl BackchannelLogoutDispatcher {
pub fn new(
storage: Arc<dyn Storage>,
token_manager: Arc<dyn TokenIssuer>,
) -> Result<Self, IssuerdError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(BACKCHANNEL_TIMEOUT_SECS))
.build()
.map_err(|e| {
IssuerdError::ServerError(format!("backchannel logout http client: {e}"))
})?;
Ok(Self {
storage,
token_manager,
http,
})
}
async fn post_logout_token(&self, uri: &str, token: &str) -> Result<(), String> {
for attempt in 1..=2 {
match self.http.post(uri).form(&[("logout_token", token)]).send().await {
Ok(resp) if resp.status().is_success() => return Ok(()),
Ok(resp) => {
debug!(
attempt,
status = %resp.status(),
uri, "backchannel logout delivery rejected"
);
if attempt == 2 {
return Err(format!("http status {}", resp.status()));
}
}
Err(e) => {
debug!(attempt, error = %e, uri, "backchannel logout delivery failed");
if attempt == 2 {
return Err(e.to_string());
}
}
}
}
unreachable!("loop returns on the final attempt")
}
async fn record(
&self,
realm: &Realm,
session_id: &SessionId,
client_name: &str,
error: Option<String>,
) {
if !realm.admin_events_enabled {
return;
}
let event = AdminEvent {
id: EventId::new(issuerd_core::utils::generate_id()).unwrap(),
realm_id: realm.id.clone(),
auth_realm_id: None,
auth_client_id: None,
auth_user_id: None,
operation_type: OperationType::Action,
resource_type: ResourceType::Session,
resource_path: format!("backchannel-logout/{client_name}"),
representation: Some(format!("session={session_id}")),
error,
event_time: chrono::Utc::now(),
};
let _ = self.storage.save_admin_event(&event).await;
}
pub async fn dispatch(&self, realm: &Realm, session: &UserSession) {
let user = match self.storage.get_user(&realm.id, &session.user_id).await {
Ok(Some(u)) => u,
Ok(None) => {
debug!(
realm = %realm.id,
session_id = %session.id,
"backchannel logout skipped: user no longer exists"
);
return;
}
Err(e) => {
warn!(realm = %realm.id, error = %e, "backchannel logout: user lookup failed");
return;
}
};
for client_session in &session.clients {
let client = match self.storage.get_client(&realm.id, &client_session.client_id).await {
Ok(Some(c)) => c,
Ok(None) => continue,
Err(e) => {
warn!(realm = %realm.id, error = %e, "backchannel logout: client lookup failed");
continue;
}
};
let Some(uri) = client
.attributes
.get(CLIENT_ATTR_BACKCHANNEL_LOGOUT_URI)
.filter(|u| !u.is_empty())
else {
continue;
};
let token = match self
.token_manager
.issue_logout_token(&user, &client, realm, &session.id)
.await
{
Ok(t) => t.token,
Err(e) => {
error!(realm = %realm.id, client_id = %client.client_id, error = %e, "backchannel logout: token issuance failed");
self.record(
realm,
&session.id,
client.client_id.as_ref(),
Some(format!("token issuance failed: {e}")),
)
.await;
continue;
}
};
let result = self.post_logout_token(uri, &token).await;
if let Err(ref e) = result {
warn!(
realm = %realm.id,
client_id = %client.client_id,
uri,
error = %e,
"backchannel logout delivery failed"
);
}
self.record(realm, &session.id, client.client_id.as_ref(), result.err()).await;
}
}
}
#[async_trait]
impl SessionLogoutNotifier for BackchannelLogoutDispatcher {
async fn notify_session_destroyed(&self, realm: &Realm, session: &UserSession) {
if session.clients.is_empty() {
return;
}
let dispatcher = Self {
storage: self.storage.clone(),
token_manager: self.token_manager.clone(),
http: self.http.clone(),
};
let realm = realm.clone();
let session = session.clone();
tokio::spawn(async move { dispatcher.dispatch(&realm, &session).await });
}
}
pub async fn frontchannel_logout_urls(
storage: &Arc<dyn Storage>,
realm_id: &RealmId,
issuer: &str,
session: &UserSession,
) -> Vec<String> {
let mut urls = Vec::new();
for client_session in &session.clients {
let client = match storage.get_client(realm_id, &client_session.client_id).await {
Ok(Some(c)) => c,
_ => continue,
};
if let Some(uri) = client
.attributes
.get(CLIENT_ATTR_FRONTCHANNEL_LOGOUT_URI)
.filter(|u| !u.is_empty())
{
urls.push(super::oidc::build_redirect_url(
uri,
&[("iss", issuer), ("sid", &session.id.0)],
false,
));
}
}
urls
}
pub fn frontchannel_logout_page(continue_url: Option<&str>, iframe_urls: &[String]) -> Response {
let mut body = String::from("<h1>Signing you out</h1>");
body.push_str("<p>You have been signed out.</p>");
for url in iframe_urls {
body.push_str(&format!(
"<iframe src=\"{}\" style=\"display:none\" title=\"logout\"></iframe>",
crate::email::html_escape(url)
));
}
if let Some(target) = continue_url {
let js_target = serde_json::to_string(target)
.unwrap_or_else(|_| "\"\"".to_string())
.replace('<', "\\u003c");
let escaped = crate::email::html_escape(target);
body.push_str(&format!(
"<p><a href=\"{escaped}\">Continue</a></p>\
<script>setTimeout(function(){{window.location.replace({js_target});}},1500);</script>\
<noscript><meta http-equiv=\"refresh\" content=\"3;url={escaped}\"></noscript>"
));
}
super::required_actions::page("Sign-out", &body).into_response()
}
pub fn issuer_for_realm(issuer_base: &str, realm_name: &str) -> String {
format!("{}/realms/{}", issuer_base.trim_end_matches('/'), realm_name)
}