use std::sync::Arc;
use tablo_ui::{ButtonSize, ButtonVariant, button_variants};
use topcoat::{
context::Cx,
icon::icon,
router::{
Body, RouteFuture,
error::{redirect, see_other},
request::method,
response::{IntoResponse, Response},
},
view::{BoxView, ViewExt, view},
};
use super::{
Authenticator, PanelUser, UNAVAILABLE_ERROR,
gate::refused,
infrastructure_failure,
login::{login_url, next_from_query},
page::{AuthPage, render},
panel_root,
realm::current_realm,
registration::with_next,
session::{fresh_session, record, session_row, sweep_expired_sessions},
};
use crate::{BoxFuture, DeclarationErrorKind, auth::AuthConfig, error::TabloError};
pub trait ExternalAuth: Send + Sync + 'static {
type User: PanelUser;
fn current_user(
&self,
cx: &Cx,
) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
fn login_url(&self, cx: &Cx, next: &str) -> String;
fn logout_url(&self, cx: &Cx) -> String;
}
pub(crate) trait DynExternal: Send + Sync {
fn current_user<'a>(
&'a self,
cx: &'a Cx,
) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
fn login_url(&self, cx: &Cx, next: &str) -> String;
fn logout_url(&self, cx: &Cx) -> String;
}
async fn app_user<E: ExternalAuth>(external: &E, cx: &Cx) -> topcoat::Result<Option<E::User>> {
external.current_user(cx).await.map_err(|error| {
tracing::error!(error = %error, "the app's sign-in failed");
TabloError::Infrastructure(UNAVAILABLE_ERROR).into()
})
}
impl<E: ExternalAuth> DynExternal for E {
fn current_user<'a>(
&'a self,
cx: &'a Cx,
) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
Box::pin(async move {
let user = app_user(self, cx).await?;
Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
})
}
fn login_url(&self, cx: &Cx, next: &str) -> String {
ExternalAuth::login_url(self, cx, next)
}
fn logout_url(&self, cx: &Cx) -> String {
ExternalAuth::logout_url(self, cx)
}
}
pub(super) struct ExternalUsers<E>(pub(super) Arc<E>);
impl<E: ExternalAuth> Authenticator for ExternalUsers<E> {
type User = E::User;
async fn verify(
&self,
_cx: &Cx,
_login: &str,
_password: &str,
) -> topcoat::Result<Option<E::User>> {
Ok(None)
}
async fn find_by_id(&self, cx: &Cx, id: &str) -> topcoat::Result<Option<E::User>> {
let user = app_user(&*self.0, cx).await?;
Ok(user.filter(|user| user.user_id() == id))
}
}
pub(super) fn external_login(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
let external = panel
.auth
.external()
.ok_or_else(topcoat::router::error::not_found)?;
let next = next_from_query(cx).unwrap_or_default();
let user = external.current_user(cx).await?;
let Some(user) = user else {
let back = with_next(login_url(cx), &next);
let back: String = form_urlencoded::byte_serialize(back.as_bytes()).collect();
return Err(redirect(external.login_url(cx, &back)).into());
};
if !user.can_access_panel(&panel.prefix) {
return no_access(cx, external.logout_url(cx)).await;
}
let holds_theirs = session_row(cx, panel)
.await
.map_err(infrastructure_failure)?
.is_some_and(|row| row.user_id == user.user_id());
if !holds_theirs && *method(cx) != http::Method::HEAD {
if let Err(error) = sweep_expired_sessions(cx).await {
tracing::error!(error = %error, "expired-session sweep failed");
}
let session = fresh_session(cx).await?;
record(cx, &session, &*user, panel).await?;
}
let target = if next.is_empty() {
panel_root(cx)
} else {
next
};
see_other(target).into_response(cx)
})
}
fn sign_out_link(cx: &Cx, url: String) -> BoxView<'_> {
view! {
<a
href=(url)
class=(button_variants(ButtonVariant::Outline, ButtonSize::Md))
data-external-sign-out=""
>
icon(data: tablo_ui::icons::LOG_OUT)
(crate::t!(cx, "tablo-shell-sign-out"))
</a>
}
.boxed()
}
async fn no_access(cx: &Cx, leave: String) -> topcoat::Result<Response> {
refused(cx, sign_out_link(cx, leave)).await
}
pub(super) async fn leave_for(cx: &Cx, url: String) -> topcoat::Result<Response> {
let refresh = format!("0;url={url}");
let form = view! {
<meta http-equiv="refresh" content=(refresh) />
(sign_out_link(cx, url))
}
.boxed();
let page = render(
cx,
AuthPage {
title: crate::t!(cx, "tablo-shell-sign-out"),
description: crate::t!(cx, "tablo-external-leaving"),
status: None,
form,
switch: None,
note: None,
},
)
.await?;
page.single().await?.into_response(cx)
}
pub(super) fn declaration_errors(auth: &AuthConfig) -> Vec<DeclarationErrorKind> {
if auth.external().is_none() {
return Vec::new();
}
[
("Auth::registration", auth.registrar().is_some()),
("Auth::password_reset", auth.reset().is_some()),
("Auth::email_verification", auth.verification().is_some()),
("Auth::two_factor", auth.two_factor_config().is_some()),
("Auth::impersonation", auth.impersonation().is_some()),
]
.into_iter()
.filter(|(_, declared)| *declared)
.map(|(feature, _)| DeclarationErrorKind::ExternalAuthFeature { feature })
.collect()
}