use std::{sync::Arc, time::Duration};
use jiff::Timestamp;
use topcoat::{
context::Cx,
session::{self, TokenHash},
};
use uuid::Uuid;
use super::{
DynAuthenticator, PanelUser, SignedIn, infrastructure_failure,
token::{CREDENTIALS, REVOCATION, TokenPurpose},
};
use crate::auth::realm::{Realm, current_realm, realms};
pub const SESSION_LIFETIME: Duration = Duration::from_hours(24 * 7);
#[derive(Debug, Clone, toasty::Model)]
pub struct AuthSession {
#[key]
pub token_hash: String,
#[index]
pub user_id: String,
pub panel: String,
pub tenant: Option<Uuid>,
#[index]
pub expires_at: Timestamp,
pub created_at: Timestamp,
}
pub(super) fn token_key(hash: &TokenHash) -> String {
use std::fmt::Write as _;
let mut key = String::with_capacity(64);
for byte in hash.iter() {
write!(key, "{byte:02x}").expect("writing to a String cannot fail");
}
key
}
pub(super) async fn record(
cx: &Cx,
session: &session::Session,
user: &dyn PanelUser,
panel: &Realm,
) -> topcoat::Result<()> {
let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
insert(
&mut crate::db::db(cx)?,
&session.token_hash,
user.user_id(),
panel.prefix.clone(),
None,
expires_at,
)
.await
.map_err(infrastructure_failure)
}
pub(super) fn credential_panels(
cx: &Cx,
user_type: std::any::TypeId,
own: &Arc<Realm>,
) -> Vec<Arc<Realm>> {
let realms = realms(cx);
if realms.is_empty() {
return vec![Arc::clone(own)];
}
realms
.into_iter()
.filter(|realm| {
realm
.auth
.authenticator()
.is_some_and(|auth| auth.user_type() == user_type)
})
.collect()
}
pub(super) async fn end_credentials(
ex: &mut dyn toasty::Executor,
user_id: &str,
panels: &[Arc<Realm>],
purposes: &[TokenPurpose],
) -> topcoat::Result<()> {
let prefixes: Vec<String> = panels.iter().map(|panel| panel.prefix.clone()).collect();
AuthSession::filter(
AuthSession::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthSession::fields().panel().in_list(prefixes)),
)
.delete()
.exec(&mut *ex)
.await
.map_err(infrastructure_failure)?;
let issuing: Vec<String> = panels
.iter()
.filter(|panel| panel.auth.issues_tokens())
.map(|panel| panel.prefix.clone())
.collect();
if issuing.is_empty() {
return Ok(());
}
super::token::end_all(ex, user_id, Some(issuing), purposes).await
}
pub(super) async fn replace_sessions(
cx: &Cx,
ex: &mut dyn toasty::Executor,
signed: &SignedIn,
user_type: std::any::TypeId,
session: &session::Session,
) -> topcoat::Result<()> {
let panels = credential_panels(cx, user_type, &signed.realm);
let user_id = signed.user.user_id();
end_credentials(&mut *ex, &user_id, &panels, CREDENTIALS).await?;
let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
insert(
ex,
&session.token_hash,
user_id,
signed.realm.prefix.clone(),
signed.tenant,
expires_at,
)
.await
.map_err(infrastructure_failure)
}
#[doc(hidden)]
pub async fn mint_session(
ex: &mut dyn toasty::Executor,
panel: &str,
user: &dyn PanelUser,
) -> topcoat::Result<String> {
let token = session::Token::random();
let expires_at = Timestamp::try_from(std::time::SystemTime::now() + SESSION_LIFETIME)
.map_err(topcoat::Error::from)?;
insert(
ex,
&token.hash(),
user.user_id(),
crate::naming::normalize_prefix(panel),
None,
expires_at,
)
.await?;
Ok(token.encode())
}
async fn insert(
ex: &mut dyn toasty::Executor,
hash: &TokenHash,
user_id: String,
panel: String,
tenant: Option<Uuid>,
expires_at: Timestamp,
) -> toasty::Result<()> {
toasty::create!(AuthSession {
token_hash: token_key(hash),
user_id,
panel,
tenant,
expires_at,
created_at: Timestamp::now(),
})
.exec(ex)
.await?;
Ok(())
}
pub(super) async fn select_tenant(cx: &Cx, tenant: Uuid) -> topcoat::Result<()> {
let Some(hash) = session::token_hash(cx).await? else {
return Err(topcoat::router::error::forbidden().into());
};
let mut db = crate::db::db(cx)?;
AuthSession::filter(AuthSession::fields().token_hash().eq(token_key(&hash)))
.update()
.tenant(Some(tenant))
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
Ok(())
}
pub(super) async fn delete_session(cx: &Cx, hash: &TokenHash) -> topcoat::Result<()> {
delete_session_row(cx, &token_key(hash)).await
}
pub(super) async fn start_once(
cx: &Cx,
started: &mut Option<session::Session>,
) -> topcoat::Result<session::Session> {
if let Some(session) = started {
return Ok(session.clone());
}
let session = session::start(cx).await?;
*started = Some(session.clone());
Ok(session)
}
pub(super) async fn fresh_session(cx: &Cx) -> topcoat::Result<session::Session> {
if let Some(hash) = session::token_hash(cx)
.await
.map_err(infrastructure_failure)?
{
delete_session(cx, &hash).await?;
}
session::start(cx).await.map_err(infrastructure_failure)
}
async fn delete_session_row(cx: &Cx, key: &str) -> topcoat::Result<()> {
let mut db = crate::db::db(cx)?;
AuthSession::filter(AuthSession::fields().token_hash().eq(key.to_string()))
.delete()
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
Ok(())
}
pub async fn revoke_sessions_for_user(cx: &Cx, user_id: &str) -> topcoat::Result<()> {
let mut db = crate::db::db(cx)?;
if let Some(panel) = current_realm(cx) {
let panels = match panel.auth.authenticator() {
Some(auth) => credential_panels(cx, auth.user_type(), panel),
None => vec![Arc::clone(panel)],
};
end_credentials(&mut db, user_id, &panels, REVOCATION).await?;
wake_bells(
cx,
user_id,
panels.iter().map(|panel| panel.prefix.as_str()),
);
return Ok(());
}
AuthSession::filter(AuthSession::fields().user_id().eq(user_id.to_string()))
.delete()
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
let realms = realms(cx);
if realms.iter().any(|realm| realm.auth.issues_tokens()) {
super::token::end_all(&mut db, user_id, None, REVOCATION).await?;
}
wake_bells(
cx,
user_id,
realms.iter().map(|realm| realm.prefix.as_str()),
);
Ok(())
}
fn wake_bells<'a>(cx: &Cx, user_id: &str, panels: impl Iterator<Item = &'a str>) {
let bus = crate::bus::bus(cx);
for panel in panels {
bus.wake(panel, user_id);
}
}
pub(super) const SESSION_SWEEP_BATCH: usize = 500;
pub(super) async fn sweep_expired_sessions(cx: &Cx) -> topcoat::Result<()> {
let now = Timestamp::now();
let mut db = crate::db::db(cx)?;
let expired: Vec<String> = AuthSession::filter(AuthSession::fields().expires_at().le(now))
.limit(SESSION_SWEEP_BATCH)
.exec(&mut db)
.await
.map_err(infrastructure_failure)?
.into_iter()
.map(|row| row.token_hash)
.collect();
if expired.is_empty() {
return Ok(());
}
AuthSession::filter(
AuthSession::fields()
.token_hash()
.in_list(expired)
.and(AuthSession::fields().expires_at().le(now)),
)
.delete()
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
Ok(())
}
pub(super) async fn session_row(cx: &Cx) -> topcoat::Result<Option<AuthSession>> {
let Some(hash) = session::token_hash(cx).await? else {
return Ok(None);
};
let key = token_key(&hash);
let mut db = crate::db::db(cx)?;
let row = AuthSession::filter(AuthSession::fields().token_hash().eq(key))
.first()
.exec(&mut db)
.await
.map_err(infrastructure_failure)?;
let Some(row) = row else {
return Ok(None);
};
if row.expires_at <= Timestamp::now() {
delete_session_row(cx, &row.token_hash).await?;
return Ok(None);
}
Ok(Some(row))
}
pub(super) async fn session_user(
cx: &Cx,
row: AuthSession,
panel: &Arc<Realm>,
authenticator: &dyn DynAuthenticator,
) -> topcoat::Result<Option<SignedIn>> {
match authenticator
.find_by_id(cx, &row.user_id)
.await
.map_err(infrastructure_failure)?
{
Some(user) => Ok(Some(SignedIn {
user,
realm: Arc::clone(panel),
tenant: row.tenant,
})),
None => {
delete_session_row(cx, &row.token_hash).await?;
Ok(None)
}
}
}
pub(super) async fn resolve(
cx: &Cx,
panel: &Arc<Realm>,
authenticator: &dyn DynAuthenticator,
) -> topcoat::Result<Option<SignedIn>> {
match session_row(cx).await? {
Some(row) if row.panel == panel.prefix => session_user(cx, row, panel, authenticator).await,
_ => Ok(None),
}
}