use std::{fmt::Display, str::FromStr};
use axum::http::request::Parts;
use clap::ValueEnum;
use hyper::StatusCode;
use thiserror::Error;
use tower_cookies::{Cookie, Cookies};
use uuid::Uuid;
use crate::errors::{ErrorKind, HttpError};
crate::make_object_id!(SessionId, sid);
#[cfg(feature = "local_auth")]
pub use session_backend::*;
#[derive(Debug, Error)]
pub enum SessionError {
#[error("Failed to access database")]
Db,
#[error("Session does not exist")]
NotFound,
}
impl HttpError for SessionError {
type Detail = ();
fn status_code(&self) -> StatusCode {
match self {
Self::Db => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_kind(&self) -> &'static str {
match self {
Self::Db => ErrorKind::Database,
Self::NotFound => ErrorKind::NotFound,
}
.as_str()
}
fn error_detail(&self) -> Self::Detail {
()
}
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum SameSiteArg {
None,
Lax,
#[default]
Strict,
}
impl Display for SameSiteArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "none"),
Self::Lax => write!(f, "lax"),
Self::Strict => write!(f, "strict"),
}
}
}
impl From<SameSiteArg> for tower_cookies::cookie::SameSite {
fn from(value: SameSiteArg) -> Self {
match value {
SameSiteArg::None => Self::None,
SameSiteArg::Lax => Self::Lax,
SameSiteArg::Strict => Self::Strict,
}
}
}
#[derive(Debug, Clone)]
pub struct SessionCookieBuilder {
secure: bool,
same_site: tower_cookies::cookie::SameSite,
}
impl SessionCookieBuilder {
pub fn new(secure: bool, same_site: impl Into<tower_cookies::cookie::SameSite>) -> Self {
Self {
secure,
same_site: same_site.into(),
}
}
pub fn create_cookie(&self, key: &SessionKey, expiry: std::time::Duration) -> Cookie<'static> {
let cookie_contents = key.to_string();
let expiry = tower_cookies::cookie::time::Duration::try_from(expiry).unwrap();
Cookie::build(("sid", cookie_contents))
.http_only(true)
.same_site(self.same_site)
.secure(self.secure)
.max_age(expiry)
.path("/")
.into()
}
}
impl Default for SessionCookieBuilder {
fn default() -> Self {
Self {
secure: true,
same_site: tower_cookies::cookie::SameSite::Strict,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ExpiryStyle {
FromCreation(std::time::Duration),
AfterIdle(std::time::Duration),
}
impl ExpiryStyle {
pub fn expiry_duration(&self) -> std::time::Duration {
match self {
ExpiryStyle::FromCreation(duration) => *duration,
ExpiryStyle::AfterIdle(duration) => *duration,
}
}
}
pub struct SessionKey {
pub session_id: SessionId,
pub hash: Uuid,
}
impl SessionKey {
pub fn new(session_id: SessionId, hash: Uuid) -> Self {
Self { session_id, hash }
}
}
impl Display for SessionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.session_id, self.hash)
}
}
impl FromStr for SessionKey {
type Err = SessionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (id, hash) = s.split_once(':').ok_or(SessionError::NotFound)?;
let id = SessionId::from_str(id).map_err(|_| SessionError::NotFound)?;
let hash = Uuid::from_str(hash).map_err(|_| SessionError::NotFound)?;
Ok(Self::new(id, hash))
}
}
pub fn get_session_cookie(request: &Parts) -> Option<SessionKey> {
let cookies = request.extensions.get::<Cookies>()?;
let sid = cookies.get("sid")?;
SessionKey::from_str(sid.value()).ok()
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn session_key() {
let sid = SessionId::new();
let hash = Uuid::new_v4();
let key = SessionKey::new(sid.clone(), hash);
let serialized = key.to_string();
let restored = SessionKey::from_str(&serialized).unwrap();
assert_eq!(restored.session_id, sid);
assert_eq!(restored.hash, hash);
}
}
#[cfg(feature = "local_auth")]
mod session_backend {
use std::str::FromStr;
use error_stack::{Report, ResultExt};
use sqlx::PgPool;
use tower_cookies::{Cookie, Cookies};
use uuid::Uuid;
use super::{ExpiryStyle, SessionCookieBuilder, SessionError, SessionId, SessionKey};
use crate::auth::UserId;
#[derive(Clone)]
pub struct SessionBackend {
pub db: PgPool,
pub cookies: SessionCookieBuilder,
pub expiry_style: ExpiryStyle,
}
impl SessionBackend {
pub fn new(db: PgPool, cookies: SessionCookieBuilder, expiry_style: ExpiryStyle) -> Self {
Self {
db,
cookies,
expiry_style,
}
}
pub async fn create_session(
&self,
cookies: &Cookies,
user_id: &UserId,
) -> Result<(), Report<SessionError>> {
let session_id = SessionId::new();
let hash = Uuid::new_v4();
sqlx::query!(
"
INSERT INTO user_sessions (id, user_id, hash, expires_at) VALUES
($1, $2, $3, now() + $4)",
session_id.as_uuid(),
user_id.as_uuid(),
&hash,
self.expiry_style.expiry_duration() as _
)
.execute(&self.db)
.await
.change_context(SessionError::Db)?;
let cookie = self.cookies.create_cookie(
&SessionKey::new(session_id, hash),
self.expiry_style.expiry_duration(),
);
cookies.add(cookie);
Ok(())
}
pub async fn touch_session(
&self,
cookies: &Cookies,
key: &SessionKey,
) -> Result<(), Report<SessionError>> {
let ExpiryStyle::AfterIdle(duration) = self.expiry_style else {
return Ok(());
};
let updated = sqlx::query!(
"UPDATE user_sessions
SET expires_at = now() + $1
WHERE id=$2 and hash=$3
-- Prevent unnecessary updates
AND (expires_at < now() + $1 - '1 minute'::interval)",
duration as _,
&key.session_id as _,
&key.hash
)
.execute(&self.db)
.await
.change_context(SessionError::Db)?;
if updated.rows_affected() > 0 {
cookies.add(self.cookies.create_cookie(&key, duration));
}
Ok(())
}
pub async fn delete_for_user(&self, id: UserId) -> Result<(), Report<SessionError>> {
sqlx::query!("DELETE FROM user_sessions WHERE user_id = $1", id.as_uuid())
.execute(&self.db)
.await
.change_context(SessionError::Db)?;
Ok(())
}
pub async fn delete_session(&self, cookies: &Cookies) -> Result<(), Report<SessionError>> {
let cookie = cookies.get("sid");
let Some(cookie) = cookie else {
return Ok(());
};
cookies.remove(Cookie::new("sid", ""));
let key = SessionKey::from_str(cookie.value())?;
sqlx::query!(
"DELETE FROM user_sessions WHERE id = $1 and hash = $2",
key.session_id.as_uuid(),
&key.hash
)
.execute(&self.db)
.await
.change_context(SessionError::Db)?;
Ok(())
}
pub async fn delete_expired_sessions(&self) -> Result<(), Report<SessionError>> {
sqlx::query!("DELETE FROM user_sessions WHERE expires_at < now()")
.execute(&self.db)
.await
.change_context(SessionError::Db)?;
Ok(())
}
}
}