use super::StatusResponse;
use super::authentication_helpers::{
JsonField, JsonFieldKind, RequestBody, json_type, parse_body, validation_response,
with_session_cookies,
};
use super::helpers::{
admin_plugin_enabled, delete_session_cookie_headers, unauthorized_if_unauthenticated,
};
use alibi_core::endpoint::{
EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
};
use alibi_core::entity::{AuthSession, AuthUser};
use alibi_core::error::codes;
use alibi_core::field_policy::{FieldInputError, FieldValues, SessionFields};
use alibi_core::session::SessionRequest;
use alibi_core::utils::cookie_utils::{related_cookie_name, verify_cookie_value};
use alibi_core::utils::json::JsValue;
use alibi_core::wire::{SessionView, UserView};
use alibi_core::{
AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
HttpMethod, SuccessResponse,
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
pub struct SessionManagementPlugin {
config: SessionManagementConfig,
}
#[derive(Debug, Clone, alibi_core::PluginConfig)]
#[plugin(name = "SessionManagementPlugin")]
pub struct SessionManagementConfig {
#[config(default = true)]
pub enable_session_listing: bool,
#[config(default = true)]
pub enable_session_revocation: bool,
#[config(default = true)]
pub require_authentication: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevokeSessionRequest {
pub token: String,
}
impl RequestBody for RevokeSessionRequest {
const FIELDS: &'static [JsonField] = &[JsonField::string("token", true)];
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignOutRequest {
#[serde(rename = "callbackURL", skip_serializing_if = "Option::is_none")]
pub callback_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_redirect: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
impl RequestBody for SignOutRequest {
const FIELDS: &'static [JsonField] = &[
JsonField::string("callbackURL", false),
JsonField {
name: "disableRedirect",
kind: JsonFieldKind::Boolean,
required: false,
},
JsonField::string("state", false),
];
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignOutResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSessionResponse {
pub session: SessionView,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetSessionResponse {
pub session: SessionView,
pub user: UserView,
#[serde(rename = "needsRefresh", skip_serializing_if = "Option::is_none")]
pub needs_refresh: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GetSessionQuery {
pub disable_cookie_cache: bool,
pub disable_refresh: bool,
}
#[async_trait]
impl<S: alibi_core::AuthSchema> AuthPlugin<S> for SessionManagementPlugin {
route_openapi_metadata!(S);
fn name(&self) -> &'static str {
"session-management"
}
fn server_endpoints(&self) -> Vec<EndpointDefinition> {
let mut endpoints = vec![crate::endpoint::definition(
"getSession",
"get_session",
Some("/get-session"),
HttpMethod::Get,
)];
endpoints.push(crate::endpoint::definition(
"updateSession",
"updateSession",
Some("/update-session"),
HttpMethod::Post,
));
endpoints.push(crate::endpoint::definition(
"signOut",
"signOut",
Some("/sign-out"),
HttpMethod::Post,
));
if self.config.enable_session_listing {
endpoints.push(crate::endpoint::definition(
"listSessions",
"listSessions",
Some("/list-sessions"),
HttpMethod::Get,
));
}
if self.config.enable_session_revocation {
for (operation, path) in [
("revokeSession", "/revoke-session"),
("revokeOtherSessions", "/revoke-other-sessions"),
("revokeSessions", "/revoke-sessions"),
] {
endpoints.push(crate::endpoint::definition(
operation,
operation,
Some(path),
HttpMethod::Post,
));
}
}
endpoints
}
fn validate_endpoint(
&self,
call: &EndpointCall,
_: &AuthContext<S>,
) -> AuthResult<EndpointInput> {
if call.operation_id() == "updateSession" {
if !matches!(call.body(), Some(JsValue::Object(_))) {
return Err(crate::endpoint::validation(format!(
"[body] Invalid input: expected record, received {}",
json_type(call.body()),
)));
}
return Ok(EndpointInput {
body: call.body().cloned(),
query: call.query().cloned(),
});
}
if call.operation_id() == "signOut" {
let body = call
.body()
.map(|body| {
crate::endpoint::validate_fields(Some(body), "body", SignOutRequest::FIELDS)
})
.transpose()?;
if call.headers().is_none() {
return Err(crate::endpoint::validation("Headers is required"));
}
return Ok(EndpointInput {
body,
query: call.query().cloned(),
});
}
if call.operation_id() == "listSessions" {
if call.headers().is_none() {
return Err(crate::endpoint::validation("Headers is required"));
}
return Ok(EndpointInput {
body: call.body().cloned(),
query: call.query().cloned(),
});
}
if matches!(
call.operation_id(),
"revokeSession" | "revokeOtherSessions" | "revokeSessions"
) {
let body = if call.operation_id() == "revokeSession" {
Some(crate::endpoint::validate_fields(
call.body(),
"body",
RevokeSessionRequest::FIELDS,
)?)
} else {
call.body().cloned()
};
if call.headers().is_none() {
return Err(crate::endpoint::validation("Headers is required"));
}
return Ok(EndpointInput {
body,
query: call.query().cloned(),
});
}
let query = match call.query() {
None => None,
Some(JsValue::Object(input)) => Some(JsValue::Object(
input
.iter()
.filter(|(name, _)| {
["disableCookieCache", "disableRefresh"].contains(&name.as_str())
})
.map(|(name, value)| (name.clone(), JsValue::Bool(value.is_truthy())))
.collect(),
)),
Some(value) => {
return Err(crate::endpoint::validation(format!(
"[query] Invalid input: expected object, received {}",
json_type(Some(value)),
)));
}
};
if call.headers().is_none() {
return Err(crate::endpoint::validation("Headers is required"));
}
Ok(EndpointInput {
body: call.body().cloned(),
query,
})
}
async fn on_endpoint(
&self,
call: &EndpointCall,
ctx: &AuthContext<S>,
) -> AuthResult<EndpointResponse> {
if call.operation_id() == "updateSession" {
return self.update_session_endpoint_response(call, ctx).await;
}
if call.operation_id() == "signOut" {
let body = if call.body().is_some() {
call.body_as()?
} else {
SignOutRequest::default()
};
let response = self.sign_out_response(call, &body, ctx).await?;
let mut result =
EndpointResponse::value(alibi_core::utils::json::from_slice(&response.body)?);
for (name, value) in response.headers {
if !name.eq_ignore_ascii_case("content-type") {
result = result.with_header(name, value);
}
}
return Ok(result);
}
if call.operation_id() == "listSessions" {
let (user, session) = ctx
.require_cached_session(call)
.await
.map_err(unauthorized_if_unauthenticated)?;
call.record_authenticated_session(
match &user {
alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
},
session.clone(),
);
if !ctx.session_manager().is_session_fresh(&session) {
return Err(AuthError::Upstream {
status: 403,
code: codes::SESSION_NOT_FRESH,
message: "Session is not fresh",
});
}
let sessions = match list_sessions_core(user.id(), ctx).await {
Ok(sessions) => sessions,
Err(error) => {
tracing::error!(error = %error, "Session listing failed");
return Ok(EndpointResponse::error(AuthError::Api {
status: 500,
code: None,
message: String::new(),
})
.with_error_body(JsValue::Null));
}
};
return EndpointResponse::json(&sessions);
}
if matches!(
call.operation_id(),
"revokeSession" | "revokeOtherSessions" | "revokeSessions"
) {
let (user, session) = ctx
.require_authoritative_session_record(call)
.await
.map_err(|_| AuthError::Upstream {
status: 401,
code: codes::UNAUTHORIZED,
message: "Unauthorized",
})?;
call.record_authenticated_session(ctx.user_view(&user), session.clone());
let response = if call.operation_id() == "revokeSession" {
let input: RevokeSessionRequest = call.body_as()?;
revoke_session_core(&user, &input.token, ctx).await?
} else if call.operation_id() == "revokeOtherSessions" {
revoke_other_sessions_core(user.id(), &session, ctx).await?
} else {
revoke_sessions_core(user.id(), ctx).await?
};
return EndpointResponse::json(&response);
}
let response = match self.get_session_response(call, ctx).await {
Ok(value) => EndpointResponse::json(&value)?,
Err(error) => EndpointResponse::error(error),
};
Ok(response
.with_header("cache-control", "no-store")
.with_header("pragma", "no-cache"))
}
fn routes(&self) -> Vec<AuthRoute> {
vec![
AuthRoute::get("/get-session", "get_session"),
AuthRoute::post("/get-session", "get_session"),
AuthRoute::post("/sign-out", "sign_out"),
AuthRoute::post("/update-session", "updateSession"),
AuthRoute::get("/list-sessions", "list_sessions"),
AuthRoute::post("/revoke-session", "revoke_session"),
AuthRoute::post("/revoke-sessions", "revoke_sessions"),
AuthRoute::post("/revoke-other-sessions", "revoke_other_sessions"),
]
}
async fn on_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
let response = match (req.method(), req.path()) {
(HttpMethod::Get | HttpMethod::Post, "/get-session") => {
self.handle_get_session(req, ctx).await?
}
(HttpMethod::Post, "/sign-out") => self.handle_sign_out(req, ctx).await?,
(HttpMethod::Post, "/update-session") => self.handle_update_session(req, ctx).await?,
(HttpMethod::Get, "/list-sessions") if self.config.enable_session_listing => {
self.handle_list_sessions(req, ctx).await?
}
(HttpMethod::Post, "/revoke-session") if self.config.enable_session_revocation => {
self.handle_revoke_session(req, ctx).await?
}
(HttpMethod::Post, "/revoke-sessions") if self.config.enable_session_revocation => {
self.handle_revoke_sessions(req, ctx).await?
}
(HttpMethod::Post, "/revoke-other-sessions")
if self.config.enable_session_revocation =>
{
self.handle_revoke_other_sessions(req, ctx).await?
}
_ => return Ok(None),
};
Ok(Some(response))
}
}
impl SessionManagementPlugin {
#[must_use]
pub const fn get_session_endpoint() -> ServerEndpoint<Option<GetSessionResponse>> {
ServerEndpoint::new("session-management", "getSession")
}
#[must_use]
pub const fn sign_out_endpoint() -> ServerEndpoint<SignOutResponse> {
ServerEndpoint::new("session-management", "signOut")
}
#[must_use]
pub const fn list_sessions_endpoint() -> ServerEndpoint<Vec<SessionView>> {
ServerEndpoint::new("session-management", "listSessions")
}
pub fn revoke_session_endpoint(
input: &RevokeSessionRequest,
) -> AuthResult<ServerEndpoint<alibi_core::StatusResponse>> {
ServerEndpoint::new("session-management", "revokeSession").with_body(input)
}
#[must_use]
pub const fn revoke_other_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
ServerEndpoint::new("session-management", "revokeOtherSessions")
}
#[must_use]
pub const fn revoke_sessions_endpoint() -> ServerEndpoint<alibi_core::StatusResponse> {
ServerEndpoint::new("session-management", "revokeSessions")
}
pub fn update_session_endpoint(
input: &impl Serialize,
) -> AuthResult<ServerEndpoint<UpdateSessionResponse>> {
ServerEndpoint::new("session-management", "updateSession").with_body(input)
}
async fn update_session_endpoint_response<S: alibi_core::AuthSchema>(
&self,
call: &EndpointCall,
ctx: &AuthContext<S>,
) -> AuthResult<EndpointResponse> {
let (user, session) = ctx
.require_cached_session(call)
.await
.map_err(unauthorized_if_unauthenticated)?;
call.record_authenticated_session(
match &user {
alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
},
session.clone(),
);
let input = call
.body()
.and_then(JsValue::as_object)
.ok_or_else(|| crate::endpoint::validation("[body] Invalid input: expected record"))?;
let allowed = parse_session_update_fields(input, ctx).map_err(|error| match error {
FieldInputError::Validation { code, message } => AuthError::Api {
status: 400,
code: Some(code.into()),
message,
},
FieldInputError::Transform(error) => error,
})?;
let response = self
.update_session_response(call, ctx, &user, &session, allowed)
.await?;
let value: JsValue = alibi_core::utils::json::from_slice(&response.body)?;
let mut result = if response.status >= 400 {
EndpointResponse::error(AuthError::Api {
status: response.status,
code: value
.get("code")
.and_then(JsValue::as_str)
.map(str::to_owned),
message: value
.get("message")
.and_then(JsValue::as_str)
.unwrap_or_default()
.into(),
})
.with_error_body(value)
} else {
EndpointResponse::value(value)
};
for (name, value) in response.headers {
if !name.eq_ignore_ascii_case("content-type") {
result = result.with_header(name, value);
}
}
Ok(result)
}
async fn handle_update_session(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
if req.body.is_some() {
let content_type = req
.header("content-type")
.map(String::as_str)
.unwrap_or_default();
if !content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_lowercase()
.contains("application/json")
{
let message = if content_type.is_empty() {
"Content-Type is required. Allowed types: application/json".to_owned()
} else {
format!(
"Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
)
};
return Ok(AuthResponse::json(
415,
&serde_json::json!({"code":codes::UNSUPPORTED_MEDIA_TYPE,"message":message}),
)?);
}
}
let value: Option<JsValue> = match req.body.as_ref() {
Some(body) => match alibi_core::utils::json::from_slice(body) {
Ok(value) => Some(value),
Err(_) => {
return Ok(AuthResponse::json(
400,
&serde_json::json!({"code":codes::BAD_REQUEST,"message":"Invalid JSON in request body"}),
)?);
}
},
None => None,
};
let Some(input) = value.as_ref().and_then(JsValue::as_object) else {
return Ok(validation_response(&format!(
"[body] Invalid input: expected record, received {}",
json_type(value.as_ref())
)));
};
let (user, session) = match super::helpers::ordinary_session(req, ctx).await {
Ok(session) => session,
Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
return Ok(AuthResponse::json(
401,
&serde_json::json!({"code":codes::UNAUTHORIZED,"message":"Unauthorized"}),
)?);
}
Err(error) => return Err(error),
};
let allowed = match parse_session_update_fields(input, ctx) {
Ok(allowed) => allowed,
Err(FieldInputError::Validation { code, message }) => {
return Ok(AuthResponse::json(
400,
&serde_json::json!({"code":code,"message":message}),
)?);
}
Err(FieldInputError::Transform(AuthError::Internal(_))) => {
return Ok(AuthResponse::new(500));
}
Err(FieldInputError::Transform(error)) => return Err(error),
};
self.update_session_response(req, ctx, &user, &session, allowed)
.await
}
async fn update_session_response<S: alibi_core::AuthSchema>(
&self,
req: &impl SessionRequest,
ctx: &AuthContext<S>,
user: &alibi_core::AuthenticatedUser<S>,
session: &SessionView,
allowed: FieldValues,
) -> AuthResult<AuthResponse> {
if !allowed.has_input_fields() {
return Ok(AuthResponse::json(
400,
&serde_json::json!({"message":"No fields to update"}),
)?);
}
let updated = match ctx
.database
.update_session_fields_record(session.token(), allowed)
.await
{
Ok(updated) => updated,
Err(error @ AuthError::Internal(_)) => {
return Err(AuthError::CallbackFailure(Box::new(error)));
}
Err(error) => return Err(error),
};
let Some(updated) = updated else {
let mut response = AuthResponse::json(
401,
&serde_json::json!({"code":codes::FAILED_TO_GET_SESSION,"message":"Failed to get session"}),
)?;
for cookie in delete_session_cookie_headers(&ctx.config)? {
response.headers.append("Set-Cookie", cookie);
}
return Ok(response);
};
alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, user, &updated).await?;
let preference = related_cookie_name(&ctx.config, "dont_remember");
let dont_remember = req.session_headers().get("cookie").is_some_and(|header| {
cookie::Cookie::split_parse(header)
.flatten()
.find(|cookie| cookie.name() == preference)
.and_then(|cookie| verify_cookie_value(cookie.value(), ctx.config.current_secret()))
.is_some_and(|signed| !signed.is_empty())
});
with_session_cookies(
AuthResponse::json(
200,
&serde_json::json!({"session":ctx.session_view(&updated)}),
)?,
&ctx.config,
updated.token(),
dont_remember,
)
}
pub(crate) async fn handle_get_session(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let response = match self.get_session_response(req, ctx).await {
Ok(response) => AuthResponse::json(200, &response)?,
Err(error) => error.to_auth_response(),
};
Ok(response
.with_header("cache-control", "no-store")
.with_header("pragma", "no-cache"))
}
async fn get_session_response(
&self,
req: &impl SessionRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<Option<GetSessionResponse>> {
if req.session_method() == &HttpMethod::Post && !ctx.config.session.defer_session_refresh {
return Err(AuthError::Upstream {
status: 405,
code: codes::METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED,
message: "POST method requires deferSessionRefresh to be enabled in session config",
});
}
let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, true).await;
if let Some(call) = req.endpoint_call()
&& let Some((user, session)) = call.session_hook_snapshot()
{
call.record_authenticated_session(user, session);
}
let Some(read) = read.map_err(|error| match error {
error @ (AuthError::Upstream { .. } | AuthError::Api { .. }) => error,
_ => AuthError::Upstream {
status: 500,
code: codes::FAILED_TO_GET_SESSION,
message: "Failed to get session",
},
})?
else {
return Ok(None);
};
Ok(Some(GetSessionResponse {
session: read.session,
user: match read.user {
alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
alibi_core::AuthenticatedUser::Cached(user) => *user,
},
needs_refresh: read.needs_refresh,
}))
}
async fn handle_sign_out(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let body = if req.body.is_some() {
match parse_body::<SignOutRequest>(req) {
Ok(body) => body,
Err(response) => return Ok(response),
}
} else {
SignOutRequest::default()
};
self.sign_out_response(req, &body, ctx).await
}
async fn sign_out_response(
&self,
req: &impl SessionRequest,
body: &SignOutRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let mut current_user = None;
if let Some(token) = ctx.session_manager().extract_session_token(req) {
if let Ok(Some(session)) = ctx.database.get_session(&token).await {
if ctx
.database
.get_user_by_id(&session.user_id())
.await
.ok()
.flatten()
.is_some()
{
current_user = Some(session.user_id().into_owned());
}
_ = sign_out_core(&session, ctx).await;
} else {
_ = ctx.database.delete_session(&token).await;
}
}
let mut response = AuthResponse::json(
200,
&SignOutResponse {
success: true,
url: None,
redirect: None,
},
)?;
for cookie in alibi_core::session::cookie_cache::runtime::session_cleanup_headers(
&ctx.config,
req.session_headers(),
false,
)? {
response.headers.append("Set-Cookie", cookie);
}
if let Some(user_id) = current_user
&& let Some(url) = super::oauth::logout::provider_logout_url(&user_id, body, ctx).await
{
let redirect = body.disable_redirect != Some(true);
response.body = AuthResponse::json(
200,
&serde_json::json!({
"success": true, "url": url, "redirect": redirect,
}),
)?
.body;
if redirect {
_ = response.headers.insert("Location", url);
}
}
Ok(response)
}
async fn handle_list_sessions(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
if !ctx.session_manager().is_session_fresh(&session) {
return Err(AuthError::Upstream {
status: 403,
code: codes::SESSION_NOT_FRESH,
message: "Session is not fresh",
});
}
let Ok(sessions) = list_sessions_core(user.id(), ctx).await else {
tracing::error!("Session listing failed");
return Ok(AuthResponse::new(500).with_header("content-type", "application/json"));
};
Ok(AuthResponse::json(200, &sessions)?)
}
async fn handle_revoke_session(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let revoke_req: RevokeSessionRequest = match parse_body(req) {
Ok(v) => v,
Err(resp) => return Ok(resp),
};
let (user, _) = ctx
.require_authoritative_cached_session(req)
.await
.map_err(unauthorized_if_unauthenticated)?;
let response = revoke_session_core(&user, &revoke_req.token, ctx).await?;
Ok(AuthResponse::json(200, &response)?)
}
async fn handle_revoke_sessions(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let (user, _) = ctx
.require_authoritative_cached_session(req)
.await
.map_err(unauthorized_if_unauthenticated)?;
let response = revoke_sessions_core(user.id(), ctx).await?;
Ok(AuthResponse::json(200, &response)?)
}
async fn handle_revoke_other_sessions(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let (user, current_session) = ctx
.require_authoritative_cached_session(req)
.await
.map_err(unauthorized_if_unauthenticated)?;
let response = revoke_other_sessions_core(user.id(), ¤t_session, ctx).await?;
Ok(AuthResponse::json(200, &response)?)
}
}
impl std::fmt::Debug for SessionManagementPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionManagementPlugin")
.finish_non_exhaustive()
}
}
pub(crate) async fn sign_out_core(
session: &impl AuthSession,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<SuccessResponse> {
ctx.database.delete_session(session.token()).await?;
Ok(SuccessResponse { success: true })
}
pub(crate) async fn list_sessions_core(
user_id: impl AsRef<str>,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<Vec<SessionView>> {
let sessions = ctx
.database
.get_active_user_sessions_record(user_id.as_ref())
.await?;
let now = chrono::Utc::now();
let mut sessions: Vec<_> = sessions
.iter()
.filter(|session| session.expires_at() > now)
.map(|session| ctx.session_view(session))
.collect();
if admin_plugin_enabled(ctx) {
sessions.retain(|listed| listed.impersonated_by.is_none());
}
Ok(sessions)
}
pub(crate) async fn revoke_session_core(
user: &impl AuthUser,
token: &str,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
if let Some(session_to_revoke) = ctx.database.get_session(token).await?
&& session_to_revoke.user_id() == user.id()
{
ctx.database
.delete_session(token)
.await
.map_err(revocation_storage_error)?;
}
Ok(StatusResponse { status: true })
}
pub(crate) async fn revoke_sessions_core(
user_id: impl AsRef<str>,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
ctx.database
.delete_user_sessions(user_id.as_ref())
.await
.map_err(revocation_storage_error)?;
Ok(StatusResponse { status: true })
}
pub(crate) async fn revoke_other_sessions_core(
user_id: impl AsRef<str>,
current_session: &impl AuthSession,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
let all_sessions = ctx.session_manager().list_user_sessions(user_id).await?;
let tokens: Vec<String> = all_sessions
.into_iter()
.filter(|session| session.token() != current_session.token())
.map(|session| session.token().to_owned())
.collect();
let count = tokens.len();
let database = ctx.database.clone();
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
crate::helpers::spawn_in_request_context(async move {
_ = futures_util::future::join_all(tokens.into_iter().map(|token| {
let database = &database;
let sender = &sender;
async move {
let result = database.delete_session(&token).await;
_ = sender.send(result);
}
}))
.await;
});
for _ in 0..count {
receiver
.recv()
.await
.ok_or_else(|| AuthError::internal("Session revocation task stopped"))?
.map_err(crate::helpers::callback_failure)?;
}
Ok(StatusResponse { status: true })
}
#[expect(
clippy::needless_pass_by_value,
reason = "used by value as a `map_err` callback"
)]
fn revocation_storage_error(error: AuthError) -> AuthError {
tracing::error!(error = %error, "Session revocation failed");
AuthError::Upstream {
status: 500,
code: codes::INTERNAL_SERVER_ERROR,
message: "Internal Server Error",
}
}
fn parse_session_update_fields(
input: &indexmap::IndexMap<String, JsValue>,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> Result<FieldValues, FieldInputError> {
let fields = ctx.extensions.get::<SessionFields>().map_or_else(
|| SessionFields(ctx.config.session.additional_fields.clone()),
|fields| (*fields).clone(),
);
fields.parse_update(input)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers;
use alibi_core::config::AccountConfig;
use alibi_core::utils::cookie_utils::related_cookie_name;
use alibi_core::wire::SessionView;
use alibi_core::{CreateSession, CreateUser};
use chrono::{Duration, Utc};
#[tokio::test]
async fn test_get_session_success() {
let plugin = SessionManagementPlugin::new();
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/get-session",
Some(&session.token),
None,
);
let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body_str = String::from_utf8(response.body).unwrap();
let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(
(*(*(response_data)
.get("session")
.unwrap_or(&serde_json::Value::Null))
.get("token")
.unwrap_or(&serde_json::Value::Null))
.as_str()
.unwrap(),
session.token
);
assert_eq!(
(*(*(response_data)
.get("user")
.unwrap_or(&serde_json::Value::Null))
.get("email")
.unwrap_or(&serde_json::Value::Null))
.as_str()
.map(ToOwned::to_owned),
Some("test@example.com".to_owned())
);
}
#[tokio::test]
async fn test_get_session_unauthorized() {
let plugin = SessionManagementPlugin::new();
let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req =
test_helpers::create_auth_request_no_query(HttpMethod::Get, "/get-session", None, None);
let response = plugin.handle_get_session(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body).expect("valid JSON");
assert!(body.is_null());
}
#[tokio::test]
async fn test_sign_out_success() {
let plugin = SessionManagementPlugin::new();
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/sign-out",
Some(&session.token),
Some(b"{}".to_vec()),
);
let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body_str = String::from_utf8(response.body).unwrap();
let response_data: SuccessResponse = serde_json::from_str(&body_str).unwrap();
assert!(response_data.success);
let session_check = ctx.database.get_session(&session.token).await.unwrap();
assert!(session_check.is_none());
}
#[tokio::test]
async fn test_sign_out_clears_account_cookie_when_enabled() {
let plugin = SessionManagementPlugin::new();
let config = test_helpers::create_test_config().account(AccountConfig {
store_account_cookie: true,
..Default::default()
});
let ctx = test_helpers::create_test_context_with_config(config).await;
let (_user, session) = test_helpers::create_user_and_session(
&ctx,
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/sign-out",
Some(&session.token),
Some(b"{}".to_vec()),
);
let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
assert!(
response
.headers
.get_all("Set-Cookie")
.any(|cookie| cookie.starts_with(&account_cookie_name)),
"sign-out should clear the account_data cookie when store_account_cookie is enabled"
);
}
#[tokio::test]
async fn test_sign_out_does_not_emit_account_cookie_when_disabled() {
let plugin = SessionManagementPlugin::new();
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/sign-out",
Some(&session.token),
Some(b"{}".to_vec()),
);
let response = plugin.handle_sign_out(&req, &ctx).await.unwrap();
let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
assert!(
!response
.headers
.get_all("Set-Cookie")
.any(|cookie| cookie.starts_with(&account_cookie_name)),
"sign-out should not emit account_data clearing cookies when store_account_cookie is disabled"
);
}
#[tokio::test]
async fn test_list_sessions_success() {
let plugin = SessionManagementPlugin::new();
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let create_session2 = CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user.id.clone(),
expires_at: Utc::now() + Duration::hours(24),
ip_address: Some("192.168.1.1".to_owned()),
user_agent: Some("another-agent".to_owned()),
impersonated_by: None,
active_organization_id: None,
active_team_id: None,
};
ctx.database.create_session(create_session2).await.unwrap();
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/list-sessions",
Some(&session.token),
None,
);
let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body_str = String::from_utf8(response.body).unwrap();
let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
assert_eq!(sessions.len(), 2);
}
#[tokio::test]
async fn test_list_sessions_filters_impersonated_sessions_when_admin_plugin_is_enabled() {
let plugin = SessionManagementPlugin::new();
let (mut ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
let direct_session = CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user.id.clone(),
expires_at: Utc::now() + Duration::hours(24),
ip_address: Some("192.168.1.1".to_owned()),
user_agent: Some("another-agent".to_owned()),
impersonated_by: None,
active_organization_id: None,
active_team_id: None,
};
ctx.database.create_session(direct_session).await.unwrap();
let impersonated_session = CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user.id.clone(),
expires_at: Utc::now() + Duration::hours(24),
ip_address: Some("10.0.0.5".to_owned()),
user_agent: Some("impersonated-agent".to_owned()),
impersonated_by: Some("admin-user".to_owned()),
active_organization_id: None,
active_team_id: None,
};
let impersonated = ctx
.database
.create_session(impersonated_session)
.await
.unwrap();
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/list-sessions",
Some(&session.token),
None,
);
let response = plugin.handle_list_sessions(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body_str = String::from_utf8(response.body).unwrap();
let sessions: Vec<SessionView> = serde_json::from_str(&body_str).unwrap();
assert_eq!(sessions.len(), 2);
assert!(
sessions
.iter()
.all(|candidate| candidate.token != impersonated.token),
"impersonated sessions should not be returned from /list-sessions"
);
}
#[tokio::test]
async fn test_revoke_session_success() {
let plugin = SessionManagementPlugin::new();
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let create_session2 = CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user.id.clone(),
expires_at: Utc::now() + Duration::hours(24),
ip_address: Some("192.168.1.1".to_owned()),
user_agent: Some("another-agent".to_owned()),
impersonated_by: None,
active_organization_id: None,
active_team_id: None,
};
let session2 = ctx.database.create_session(create_session2).await.unwrap();
let body = serde_json::json!({ "token": session2.token });
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/revoke-session",
Some(&session.token),
Some(body.to_string().into_bytes()),
);
let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let session2_check = ctx.database.get_session(&session2.token).await.unwrap();
assert!(session2_check.is_none());
let session1_check = ctx.database.get_session(&session.token).await.unwrap();
assert!(session1_check.is_some());
}
#[tokio::test]
async fn test_revoke_session_forbidden_different_user() {
let plugin = SessionManagementPlugin::new();
let (ctx, _user1, session1) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let create_user2 = CreateUser::new()
.with_email("user2@example.com")
.with_name("User Two");
let user2 = ctx.database.create_user(create_user2).await.unwrap();
let create_session2 = CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user2.id,
expires_at: Utc::now() + Duration::hours(24),
ip_address: Some("192.168.1.1".to_owned()),
user_agent: Some("another-agent".to_owned()),
impersonated_by: None,
active_organization_id: None,
active_team_id: None,
};
let session2 = ctx.database.create_session(create_session2).await.unwrap();
let body = serde_json::json!({ "token": session2.token });
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/revoke-session",
Some(&session1.token),
Some(body.to_string().into_bytes()),
);
let response = plugin.handle_revoke_session(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
(*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
true
);
let still_exists = ctx.database.get_session(&session2.token).await.unwrap();
assert!(
still_exists.is_some(),
"other user's session must not be revoked"
);
}
#[tokio::test]
async fn test_revoke_sessions_success() {
let plugin = SessionManagementPlugin::new();
let (ctx, user, session1) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let create_session2 = CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user.id.clone(),
expires_at: Utc::now() + Duration::hours(24),
ip_address: Some("192.168.1.1".to_owned()),
user_agent: Some("another-agent".to_owned()),
impersonated_by: None,
active_organization_id: None,
active_team_id: None,
};
ctx.database.create_session(create_session2).await.unwrap();
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/revoke-sessions",
Some(&session1.token),
Some(b"{}".to_vec()),
);
let response = plugin.handle_revoke_sessions(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let user_sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
assert_eq!(user_sessions.len(), 0);
}
#[tokio::test]
async fn test_plugin_routes() {
let plugin = SessionManagementPlugin::new();
let routes = AuthPlugin::<
alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
>::routes(&plugin);
assert_eq!(routes.len(), 8);
assert!(
routes
.iter()
.any(|r| r.path == "/get-session" && r.method == HttpMethod::Get)
);
assert!(
routes
.iter()
.any(|r| r.path == "/get-session" && r.method == HttpMethod::Post)
);
assert!(
routes
.iter()
.any(|r| r.path == "/sign-out" && r.method == HttpMethod::Post)
);
assert!(
routes
.iter()
.any(|r| r.path == "/list-sessions" && r.method == HttpMethod::Get)
);
assert!(
routes
.iter()
.any(|r| r.path == "/revoke-session" && r.method == HttpMethod::Post)
);
assert!(
routes
.iter()
.any(|r| r.path == "/revoke-sessions" && r.method == HttpMethod::Post)
);
}
#[tokio::test]
async fn test_plugin_on_request_routing() {
let plugin = SessionManagementPlugin::new();
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/get-session",
Some(&session.token),
None,
);
let response = plugin.on_request(&req, &ctx).await.unwrap();
assert!(response.is_some());
assert_eq!(response.unwrap().status, 200);
let req_2 = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/get-session",
Some(&session.token),
Some(b"{}".to_vec()),
);
let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
assert_eq!(response_2.status, 405);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&response_2.body).unwrap(),
serde_json::json!({
"code": "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
"message": "POST method requires deferSessionRefresh to be enabled in session config",
}),
);
assert_eq!(
response_2.headers.get("cache-control"),
Some(&"no-store".to_owned())
);
assert_eq!(
response_2.headers.get("pragma"),
Some(&"no-cache".to_owned())
);
let req_3 = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/invalid-route",
Some(&session.token),
None,
);
let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
assert!(response_3.is_none());
}
#[tokio::test]
async fn test_configuration() {
let plugin = SessionManagementPlugin::new()
.enable_session_listing(false)
.enable_session_revocation(false)
.require_authentication(false);
assert!(!plugin.config.enable_session_listing);
assert!(!plugin.config.enable_session_revocation);
assert!(!plugin.config.require_authentication);
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User"),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/list-sessions",
Some(&session.token),
None,
);
let response = plugin.on_request(&req, &ctx).await.unwrap();
assert!(response.is_none());
let req_2 = test_helpers::create_auth_request_no_query(
HttpMethod::Post,
"/revoke-session",
Some(&session.token),
Some(b"{}".to_vec()),
);
let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
assert!(response_2.is_none());
}
}