use axum::{
Extension,
extract::{ConnectInfo, Json, Path, State},
http::HeaderMap,
response::IntoResponse,
};
use std::{net::SocketAddr, sync::Arc};
use crate::db::{DbPool, models::*};
use crate::error::LificError;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use super::{require_admin, require_user, with_read, with_write};
fn session_cookie(token: &str, expires_at: &str, secure: bool) -> String {
use chrono::DateTime;
let max_age = DateTime::parse_from_rfc3339(expires_at).map_or(30 * 24 * 3600, |exp| {
let exp_utc: DateTime<chrono::Utc> = exp.into();
(exp_utc - chrono::Utc::now()).num_seconds().max(0)
});
let secure_attr = if secure { "; Secure" } else { "" };
format!("lific_token={token}; Path=/; Max-Age={max_age}; HttpOnly{secure_attr}; SameSite=Lax")
}
fn clear_cookie(secure: bool) -> String {
let secure_attr = if secure { "; Secure" } else { "" };
format!("lific_token=; Path=/; Max-Age=0; HttpOnly{secure_attr}; SameSite=Lax")
}
#[derive(serde::Deserialize)]
pub(super) struct SignupRequest {
username: String,
email: String,
password: String,
display_name: Option<String>,
}
fn signup_policy_allows(
settings: &crate::db::queries::settings::InstanceSettings,
email: &str,
) -> Result<(), LificError> {
if !settings.allow_signup {
return Err(LificError::BadRequest(
"signups are closed on this instance. Ask an admin to create your account.".into(),
));
}
if !settings.signup_email_domains.is_empty() {
let domain = email.rsplit('@').next().unwrap_or("").trim().to_lowercase();
if !settings.signup_email_domains.contains(&domain) {
return Err(LificError::BadRequest(format!(
"signups on this instance are limited to: {}",
settings.signup_email_domains.join(", ")
)));
}
}
Ok(())
}
pub(super) async fn auth_signup(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Extension(trusted_proxies): Extension<Arc<[crate::ratelimit::IpNetwork]>>,
limiter: Option<Extension<std::sync::Arc<crate::ratelimit::RateLimiter>>>,
headers: HeaderMap,
Json(input): Json<SignupRequest>,
) -> Result<impl IntoResponse, LificError> {
let email_key = format!("signup:{}", input.email.to_lowercase());
let ip_key = format!(
"signup_ip:{}",
crate::ratelimit::client_ip(peer.ip(), &headers, &trusted_proxies)
);
if let Some(Extension(ref rl)) = limiter {
if !rl.check(&ip_key) {
let retry = rl.retry_after(&ip_key);
return Err(LificError::BadRequest(
crate::ratelimit::retry_after_message("too many signup attempts", retry),
));
}
if !rl.check(&email_key) {
let retry = rl.retry_after(&email_key);
return Err(LificError::BadRequest(
crate::ratelimit::retry_after_message("too many signup attempts", retry),
));
}
}
let settings = with_read(&db, crate::db::queries::settings::get)?;
signup_policy_allows(&settings, &input.email)?;
let mut new_user = CreateUser {
username: input.username,
email: input.email,
password: input.password,
display_name: input.display_name,
is_admin: false,
is_bot: false,
};
crate::db::queries::users::validate_new_user(&new_user)?;
let password = new_user.password.clone();
let password_hash =
tokio::task::spawn_blocking(move || crate::db::queries::users::hash_password(&password))
.await
.map_err(|e| LificError::Internal(format!("password hashing task failed: {e}")))??;
let (user, session) = db.transaction(|conn| {
let settings = crate::db::queries::settings::get(conn)?;
signup_policy_allows(&settings, &new_user.email)?;
new_user.is_admin = conn.query_row("SELECT COUNT(*) = 0 FROM users", [], |r| r.get(0))?;
let user =
crate::db::queries::users::insert_user_with_hash(conn, &new_user, &password_hash)?;
let session = crate::db::queries::users::create_session(
conn,
user.id,
Some(settings.session_lifetime_days * 24),
)?;
Ok((user, session))
})?;
let mut headers = HeaderMap::new();
headers.insert(
"set-cookie",
session_cookie(&session.token, &session.expires_at, auth_cfg.secure_cookies)
.parse()
.unwrap(),
);
Ok((
headers,
Json(serde_json::json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
},
"token": session.token,
"expires_at": session.expires_at,
})),
))
}
async fn authenticate_off_writer(
db: &DbPool,
identity: &str,
password: &str,
) -> Result<User, LificError> {
crate::db::queries::users::reject_oversized_password(password)?;
let challenge = with_read(db, |conn| {
Ok(crate::db::queries::users::password_challenge(
conn, identity,
))
})?;
let password = password.to_string();
let hash = challenge.hash().to_string();
let password_ok = tokio::task::spawn_blocking(move || {
crate::db::queries::users::verify_password(&password, &hash).unwrap_or(false)
})
.await
.map_err(|e| LificError::Internal(format!("password verification task failed: {e}")))?;
challenge.finish(password_ok)
}
pub(super) async fn auth_login(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Extension(trusted_proxies): Extension<Arc<[crate::ratelimit::IpNetwork]>>,
limiter: Option<Extension<std::sync::Arc<crate::ratelimit::RateLimiter>>>,
headers: HeaderMap,
Json(input): Json<LoginRequest>,
) -> Result<impl IntoResponse, LificError> {
let id_key = format!("login_id:{}", input.identity.to_lowercase());
let ip_key = format!(
"login_ip:{}",
crate::ratelimit::client_ip(peer.ip(), &headers, &trusted_proxies)
);
let reservation = match &limiter {
Some(Extension(rl)) => match crate::ratelimit::Reservation::acquire(rl, &ip_key, &id_key) {
Ok(reservation) => Some(reservation),
Err(rejected) => {
let retry = match rejected {
crate::ratelimit::ReservationRejection::First => rl.retry_after(&ip_key),
crate::ratelimit::ReservationRejection::Second => rl.retry_after(&id_key),
};
return Err(LificError::BadRequest(
crate::ratelimit::retry_after_message("too many login attempts", retry),
));
}
},
None => None,
};
let user = authenticate_off_writer(&db, &input.identity, &input.password).await?;
let verified_hash = user.password_hash.clone();
let (user, session) = db.transaction(|tx| {
let user = crate::db::queries::users::finalize_login(tx, user.id, &verified_hash)?;
let lifetime_days = crate::db::queries::settings::get(tx)?.session_lifetime_days;
let session =
crate::db::queries::users::create_session(tx, user.id, Some(lifetime_days * 24))?;
Ok((user, session))
})?;
if let Some(reservation) = reservation {
reservation.refund();
}
let mut headers = HeaderMap::new();
headers.insert(
"set-cookie",
session_cookie(&session.token, &session.expires_at, auth_cfg.secure_cookies)
.parse()
.unwrap(),
);
Ok((
headers,
Json(serde_json::json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
},
"token": session.token,
"expires_at": session.expires_at,
})),
))
}
pub(super) async fn auth_auto_login(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
) -> Result<impl IntoResponse, LificError> {
let conn = db.write()?;
let settings = crate::db::queries::settings::get(&conn)?;
if !settings.web_auto_login && auth_cfg.required {
return Err(LificError::Forbidden(
"single-user auto-login is not enabled on this instance".into(),
));
}
let admin =
crate::resolve_caller::resolve_caller_conn(&conn, None, crate::actor::Transport::Web)?
.ok_or_else(|| {
LificError::BadRequest("no admin account exists to sign in as".into())
})?;
let admin = admin.user;
let session = crate::db::queries::users::create_session(
&conn,
admin.id,
Some(settings.session_lifetime_days * 24),
)?;
drop(conn);
let mut headers = HeaderMap::new();
headers.insert(
"set-cookie",
session_cookie(&session.token, &session.expires_at, auth_cfg.secure_cookies)
.parse()
.unwrap(),
);
Ok((
headers,
Json(serde_json::json!({
"user": {
"id": admin.id,
"username": admin.username,
"display_name": admin.display_name,
"is_admin": admin.is_admin,
},
"token": session.token,
"expires_at": session.expires_at,
})),
))
}
pub(super) async fn auth_logout(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
headers: axum::http::HeaderMap,
) -> Result<impl IntoResponse, LificError> {
let token = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v: &str| v.strip_prefix("Bearer "))
.map(|s: &str| s.trim())
.ok_or_else(|| LificError::BadRequest("missing authorization header".into()))?;
if token.starts_with("lific_sess_") {
let conn = db.write()?;
crate::db::queries::users::delete_session(&conn, token)?;
}
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
"set-cookie",
clear_cookie(auth_cfg.secure_cookies).parse().unwrap(),
);
Ok((resp_headers, Json(serde_json::json!({"logged_out": true}))))
}
pub(super) async fn instance_info(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
) -> Result<Json<serde_json::Value>, LificError> {
let (settings, has_users) = with_read(&db, |conn| {
let settings = crate::db::queries::settings::get(conn)?;
let has_users = crate::db::queries::users::has_human_users(conn)?;
Ok((settings, has_users))
})?;
Ok(Json(serde_json::json!({
"allow_signup": settings.allow_signup,
"has_users": has_users,
"instance_name": settings.instance_name,
"login_message": settings.login_message,
"web_auto_login": settings.web_auto_login || !auth_cfg.required,
})))
}
fn settings_json(s: &crate::db::queries::settings::InstanceSettings) -> serde_json::Value {
serde_json::json!({
"allow_signup": s.allow_signup,
"instance_name": s.instance_name,
"signup_email_domains": s.signup_email_domains,
"session_lifetime_days": s.session_lifetime_days,
"login_message": s.login_message,
"web_auto_login": s.web_auto_login,
"authz_enforced": s.authz_enforced,
})
}
pub(super) async fn instance_settings_get(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<serde_json::Value>, LificError> {
require_admin(&identity)?;
let s = with_read(&db, crate::db::queries::settings::get)?;
Ok(Json(settings_json(&s)))
}
#[derive(serde::Deserialize)]
pub(super) struct InstanceSettingsPatchReq {
allow_signup: Option<bool>,
instance_name: Option<String>,
signup_email_domains: Option<Vec<String>>,
session_lifetime_days: Option<i64>,
login_message: Option<String>,
web_auto_login: Option<bool>,
authz_enforced: Option<bool>,
}
pub(super) async fn instance_settings_patch(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
reachability: Option<Extension<crate::server::Reachability>>,
headers: HeaderMap,
Json(input): Json<InstanceSettingsPatchReq>,
) -> Result<Json<serde_json::Value>, LificError> {
require_admin(&identity)?;
let admin = require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
if input.web_auto_login == Some(true)
&& let Some(Extension(reach)) = reachability.as_ref()
&& let Some(exposure) = reach.public_exposure()
{
return Err(LificError::BadRequest(format!(
"single-user web auto-login cannot be enabled while {exposure}. Anyone \
who can reach this instance would get an admin session without a \
password. Bind to 127.0.0.1 and remove the public URL first."
)));
}
let patch = crate::db::queries::settings::InstanceSettingsPatch {
allow_signup: input.allow_signup,
instance_name: input.instance_name,
signup_email_domains: input.signup_email_domains,
session_lifetime_days: input.session_lifetime_days,
login_message: input.login_message,
web_auto_login: input.web_auto_login,
authz_enforced: input.authz_enforced,
};
let authz_enforced = input.authz_enforced;
let (s, authz_changed) = db.transaction(move |tx| {
let fresh = crate::auth::revalidate_recent_session(tx, &session_token, admin.id)?;
crate::auth::require_fresh_admin(&fresh)?;
let previous_authz_enforced = crate::db::queries::settings::get(tx)?.authz_enforced;
let settings = crate::db::queries::settings::update(tx, patch)?;
let authz_changed =
authz_enforced.is_some_and(|_| settings.authz_enforced != previous_authz_enforced);
Ok((settings, authz_changed))
})?;
if authz_changed {
realtime.send(RealtimeEvent::ResyncRequired);
}
Ok(Json(settings_json(&s)))
}
pub(super) async fn auth_me(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
let full = with_read(&db, |conn| {
crate::db::queries::users::get_user_by_id(conn, user.id)
})?;
Ok(Json(serde_json::json!({
"id": full.id,
"username": full.username,
"email": full.email,
"display_name": full.display_name,
"is_admin": full.is_admin,
})))
}
#[derive(serde::Deserialize)]
pub(super) struct UpdateMeRequest {
display_name: Option<String>,
email: Option<String>,
}
pub(super) async fn update_me(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Json(input): Json<UpdateMeRequest>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
let full = with_write(&db, |conn| {
crate::db::queries::users::update_profile(
conn,
user.id,
input.display_name.as_deref(),
input.email.as_deref(),
)
})?;
Ok(Json(serde_json::json!({
"id": full.id,
"username": full.username,
"email": full.email,
"display_name": full.display_name,
"is_admin": full.is_admin,
})))
}
#[derive(serde::Deserialize)]
pub(super) struct ChangePasswordRequest {
current_password: String,
new_password: String,
}
#[derive(serde::Deserialize)]
pub(super) struct RefreshSessionRequest {
#[serde(default)]
password: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn refresh_session(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Extension(trusted_proxies): Extension<Arc<[crate::ratelimit::IpNetwork]>>,
limiter: Option<Extension<std::sync::Arc<crate::ratelimit::RateLimiter>>>,
headers: HeaderMap,
body: Option<Json<RefreshSessionRequest>>,
) -> Result<impl IntoResponse, LificError> {
let caller = require_user(&identity)?;
let session_token = crate::auth::session_bearer_token(&headers)?;
let supplied_password = body.and_then(|Json(input)| input.password);
let (passwordless, current_hash) = with_read(&db, |conn| {
let settings = crate::db::queries::settings::get(conn)?;
let user = crate::db::queries::users::get_user_by_id(conn, caller.id)?;
Ok((
settings.web_auto_login || !auth_cfg.required,
user.password_hash,
))
})?;
let ip_key = format!(
"reauth_ip:{}",
crate::ratelimit::client_ip(peer.ip(), &headers, &trusted_proxies)
);
let user_key = format!("reauth_user:{}", caller.id);
let reservation = match (&supplied_password, &limiter) {
(Some(_), Some(Extension(rl))) => {
match crate::ratelimit::Reservation::acquire(rl, &ip_key, &user_key) {
Ok(reservation) => Some(reservation),
Err(rejected) => {
let retry = match rejected {
crate::ratelimit::ReservationRejection::First => rl.retry_after(&ip_key),
crate::ratelimit::ReservationRejection::Second => rl.retry_after(&user_key),
};
return Err(LificError::BadRequest(
crate::ratelimit::retry_after_message(
"too many confirmation attempts",
retry,
),
));
}
}
}
_ => None,
};
let verified_hash = match supplied_password {
Some(password) => {
crate::db::queries::users::reject_oversized_password(&password)?;
let hash = current_hash.clone();
let ok = tokio::task::spawn_blocking(move || {
crate::db::queries::users::verify_password(&password, &hash).unwrap_or(false)
})
.await
.map_err(|e| LificError::Internal(format!("password verification task failed: {e}")))?;
if !ok {
return Err(LificError::BadRequest("incorrect password".into()));
}
Some(current_hash)
}
None => {
if !passwordless {
return Err(LificError::BadRequest(
"your password is required to confirm this".into(),
));
}
None
}
};
let (user, session) = db.transaction(|tx| {
let user =
crate::db::queries::users::validate_session(tx, &session_token).map_err(|_| {
LificError::BadRequest(crate::db::queries::users::INVALID_SESSION_MESSAGE.into())
})?;
if user.id != caller.id {
return Err(LificError::BadRequest(
crate::db::queries::users::INVALID_SESSION_MESSAGE.into(),
));
}
if !crate::db::queries::users::credential_is_live(tx, &user)? {
return Err(LificError::BadRequest(
"this account has been deactivated. Ask an admin to restore it.".into(),
));
}
let settings = crate::db::queries::settings::get(tx)?;
match &verified_hash {
Some(hash) => {
if &user.password_hash != hash {
return Err(LificError::BadRequest("incorrect password".into()));
}
}
None => {
if !settings.web_auto_login && auth_cfg.required {
return Err(LificError::BadRequest(
"your password is required to confirm this".into(),
));
}
}
}
crate::db::queries::users::delete_session(tx, &session_token)?;
let session = crate::db::queries::users::create_session(
tx,
user.id,
Some(settings.session_lifetime_days * 24),
)?;
Ok((user, session))
})?;
if let Some(reservation) = reservation {
reservation.refund();
}
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
"set-cookie",
session_cookie(&session.token, &session.expires_at, auth_cfg.secure_cookies)
.parse()
.unwrap(),
);
Ok((
resp_headers,
Json(serde_json::json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
},
"token": session.token,
"expires_at": session.expires_at,
})),
))
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn change_password(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(realtime): Extension<crate::realtime::RealtimeHub>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Extension(trusted_proxies): Extension<Arc<[crate::ratelimit::IpNetwork]>>,
limiter: Option<Extension<std::sync::Arc<crate::ratelimit::RateLimiter>>>,
headers: HeaderMap,
Json(input): Json<ChangePasswordRequest>,
) -> Result<impl IntoResponse, LificError> {
let user = require_user(&identity)?;
crate::db::queries::users::reject_oversized_password(&input.current_password)?;
let ip_key = format!(
"password_change_ip:{}",
crate::ratelimit::client_ip(peer.ip(), &headers, &trusted_proxies)
);
let user_key = format!("password_change_user:{}", user.id);
let reservation = match &limiter {
Some(Extension(rl)) => {
match crate::ratelimit::Reservation::acquire(rl, &ip_key, &user_key) {
Ok(reservation) => Some(reservation),
Err(rejected) => {
let retry = match rejected {
crate::ratelimit::ReservationRejection::First => rl.retry_after(&ip_key),
crate::ratelimit::ReservationRejection::Second => rl.retry_after(&user_key),
};
return Err(LificError::BadRequest(
crate::ratelimit::retry_after_message(
"too many password change attempts",
retry,
),
));
}
}
}
None => None,
};
let captured_hash = with_read(&db, |conn| {
Ok(crate::db::queries::users::get_user_by_id(conn, user.id)?.password_hash)
})?;
let current_password = input.current_password.clone();
let new_password = input.new_password.clone();
let verify_hash = captured_hash.clone();
let prepared_hash = tokio::task::spawn_blocking(move || {
let ok = crate::db::queries::users::verify_password(¤t_password, &verify_hash)?;
if !ok {
return Err(LificError::BadRequest(
"current password is incorrect".into(),
));
}
crate::db::queries::users::prepare_new_password(&new_password)
})
.await
.map_err(|e| LificError::Internal(format!("password hashing task failed: {e}")))??;
let session = db.transaction(|tx| {
let full = crate::db::queries::users::get_user_by_id(tx, user.id)?;
if !crate::db::queries::users::credential_is_live(tx, &full)? {
return Err(LificError::BadRequest(
"this account has been deactivated. Ask an admin to restore it.".into(),
));
}
if full.password_hash != captured_hash {
return Err(LificError::BadRequest(
"current password is incorrect".into(),
));
}
crate::db::queries::users::update_password_hash(tx, user.id, &prepared_hash)?;
crate::db::queries::users::lock_down_account(tx, user.id)?;
crate::db::queries::users::create_session(tx, user.id, None)
})?;
if let Some(reservation) = reservation {
reservation.refund();
}
realtime.revoke_user(user.id);
let mut headers = HeaderMap::new();
headers.insert(
"set-cookie",
session_cookie(&session.token, &session.expires_at, auth_cfg.secure_cookies)
.parse()
.unwrap(),
);
Ok((
headers,
Json(serde_json::json!({
"ok": true,
"token": session.token,
"expires_at": session.expires_at,
})),
))
}
pub(super) async fn revoke_all_sessions(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(realtime): Extension<crate::realtime::RealtimeHub>,
) -> Result<impl IntoResponse, LificError> {
let user = require_user(&identity)?;
with_write(&db, |conn| {
crate::db::queries::savepoint(conn, "revoke_all_sessions", || {
crate::db::queries::users::lock_down_account(conn, user.id)
})
})?;
realtime.revoke_user(user.id);
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
"set-cookie",
clear_cookie(auth_cfg.secure_cookies).parse().unwrap(),
);
Ok((resp_headers, Json(serde_json::json!({ "revoked": true }))))
}
pub(super) async fn list_keys(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<Vec<UserApiKey>>, LificError> {
let user = require_user(&identity)?;
with_read(&db, |conn| {
crate::db::queries::users::list_user_keys(conn, user.id)
})
.map(Json)
}
#[derive(serde::Deserialize)]
pub(super) struct CreateKeyRequest {
name: String,
}
pub(super) async fn create_key(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(manager): Extension<std::sync::Arc<api_keys_simplified::ApiKeyManagerV0>>,
headers: HeaderMap,
Json(input): Json<CreateKeyRequest>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
let name = input.name.trim().to_string();
if name.is_empty() {
return Err(LificError::BadRequest("key name cannot be empty".into()));
}
let prepared = crate::auth::PreparedApiKey::generate(&manager)?;
let plaintext = db.transaction(|tx| {
crate::auth::revalidate_recent_session(tx, &session_token, user.id)?;
prepared.insert(tx, &name, None, Some(user.id))
})?;
Ok(Json(serde_json::json!({
"name": name,
"key": plaintext,
})))
}
pub(super) async fn revoke_key(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
db.transaction(|tx| {
let caller = crate::auth::fresh_caller(tx, user.id)?;
crate::db::queries::users::revoke_user_key(tx, id, caller.id, caller.is_admin)
})?;
Ok(Json(serde_json::json!({"revoked": true})))
}
pub(super) async fn list_bots(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<Vec<Bot>>, LificError> {
let user = require_user(&identity)?;
with_read(&db, |conn| {
crate::db::queries::users::list_bots(conn, user.id)
})
.map(Json)
}
#[derive(serde::Deserialize)]
pub(super) struct CreateBotRequest {
tool: String,
}
pub(super) async fn create_bot(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(manager): Extension<std::sync::Arc<api_keys_simplified::ApiKeyManagerV0>>,
headers: HeaderMap,
Json(input): Json<CreateBotRequest>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
let tool = input.tool.trim().to_lowercase();
let display_name = match tool.as_str() {
"opencode" => "OpenCode",
"cursor" => "Cursor",
"claude-code" => "Claude Code",
"claude" => "Claude Desktop",
"codex" => "Codex",
"pi" => "Pi",
"vscode" => "VS Code",
"zed" => "Zed",
_ => return Err(LificError::BadRequest(format!("unknown tool: {tool}"))),
};
let bot_username = format!("{tool}-{}", user.username);
let prepared = crate::auth::PreparedApiKey::generate(&manager)?;
let (bot_user, plaintext_key) = db.transaction(|tx| {
crate::auth::revalidate_recent_session(tx, &session_token, user.id)?;
let bot_user = crate::db::queries::users::ensure_bot(tx, user.id, &tool, display_name)?;
if crate::db::queries::users::bot_is_connected(tx, bot_user.id)? {
return Err(LificError::BadRequest(format!(
"{display_name} is already connected"
)));
}
let plaintext_key = prepared.insert(tx, &bot_username, None, Some(bot_user.id))?;
Ok((bot_user, plaintext_key))
})?;
Ok(Json(serde_json::json!({
"bot": {
"id": bot_user.id,
"username": bot_user.username,
"display_name": bot_user.display_name,
},
"key": plaintext_key,
"tool": tool,
})))
}
pub(super) async fn disconnect_bot(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
db.transaction(|tx| {
let caller = crate::auth::fresh_caller(tx, user.id)?;
crate::db::queries::users::disconnect_bot(tx, id, caller.id, caller.is_admin)
})?;
Ok(Json(serde_json::json!({"disconnected": true})))
}
pub(super) async fn delete_bot(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = require_user(&identity)?;
db.transaction(|tx| {
let caller = crate::auth::fresh_caller(tx, user.id)?;
crate::db::queries::users::delete_bot(tx, id, caller.id, caller.is_admin)
})?;
Ok(Json(serde_json::json!({"deleted": true})))
}
#[derive(serde::Serialize)]
pub(super) struct UserListItem {
id: i64,
username: String,
display_name: String,
is_admin: bool,
is_active: bool,
created_at: String,
}
impl From<User> for UserListItem {
fn from(u: User) -> Self {
UserListItem {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
is_active: u.is_active,
created_at: u.created_at,
}
}
}
pub(super) async fn list_users(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<Vec<UserListItem>>, LificError> {
require_user(&identity)?;
with_read(&db, |conn| {
let users = crate::db::queries::users::list_users(conn)?;
Ok(users
.into_iter()
.filter(|u| !u.is_bot)
.map(UserListItem::from)
.collect())
})
.map(Json)
}
#[derive(serde::Deserialize)]
pub(super) struct CreateUserRequest {
username: String,
password: String,
email: Option<String>,
display_name: Option<String>,
#[serde(default)]
is_admin: bool,
}
pub(super) async fn create_user_handler(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
headers: HeaderMap,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<UserListItem>, LificError> {
require_admin(&identity)?;
let admin = require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
let username = input.username.trim().to_string();
let email = match input.email.as_deref().map(str::trim) {
Some(e) if !e.is_empty() => e.to_string(),
_ => format!("{username}@local"),
};
let user = db.transaction(|tx| {
let fresh = crate::auth::revalidate_recent_session(tx, &session_token, admin.id)?;
crate::auth::require_fresh_admin(&fresh)?;
crate::db::queries::users::create_user(
tx,
&CreateUser {
username: username.clone(),
email,
password: input.password.clone(),
display_name: input.display_name.clone(),
is_admin: input.is_admin,
is_bot: false,
},
)
})?;
Ok(Json(user.into()))
}
pub(super) async fn promote_user(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
headers: HeaderMap,
) -> Result<Json<UserListItem>, LificError> {
require_admin(&identity)?;
let admin = require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
let user = db.transaction(|tx| {
let fresh = crate::auth::revalidate_recent_session(tx, &session_token, admin.id)?;
crate::auth::require_fresh_admin(&fresh)?;
crate::db::queries::users::set_admin_guarded(tx, id, true)
})?;
Ok(Json(user.into()))
}
pub(super) async fn demote_user(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<Json<UserListItem>, LificError> {
require_admin(&identity)?;
let caller = require_user(&identity)?;
let user = db.transaction(|tx| {
let fresh = crate::auth::fresh_caller(tx, caller.id)?;
crate::auth::require_fresh_admin(&fresh)?;
crate::db::queries::users::set_admin_guarded(tx, id, false)
})?;
Ok(Json(user.into()))
}
pub(super) async fn deactivate_user(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(realtime): Extension<crate::realtime::RealtimeHub>,
) -> Result<Json<UserListItem>, LificError> {
require_admin(&identity)?;
let caller = require_user(&identity)?;
let (user, scoped) = db.transaction(|tx| {
let fresh = crate::auth::fresh_caller(tx, caller.id)?;
crate::auth::require_fresh_admin(&fresh)?;
let user = crate::db::queries::users::set_active(tx, id, false)?;
let scoped = crate::db::queries::users::owned_bot_ids(tx, user.id)?;
Ok((user, scoped))
})?;
realtime.revoke_user(user.id);
for bot_id in scoped {
realtime.revoke_user(bot_id);
}
Ok(Json(user.into()))
}
pub(super) async fn reactivate_user(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
headers: HeaderMap,
) -> Result<Json<UserListItem>, LificError> {
require_admin(&identity)?;
let admin = require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
let user = db.transaction(|tx| {
let fresh = crate::auth::revalidate_recent_session(tx, &session_token, admin.id)?;
crate::auth::require_fresh_admin(&fresh)?;
crate::db::queries::users::set_active(tx, id, true)
})?;
Ok(Json(user.into()))
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use axum::http::StatusCode;
mod lockdown {
use crate::api::test_helpers::{test_peer, with_client_ip_test_layers};
use crate::db::DbPool;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use rusqlite::params;
use tower::ServiceExt;
const PASSWORD: &str = "correct horse battery";
struct Fixture {
db: DbPool,
app: axum::Router,
user_id: i64,
bot_id: i64,
session: String,
human_key: String,
bot_key: String,
human_oauth: String,
bot_oauth: String,
stranger_id: i64,
stranger_key: String,
operator_key: String,
limiter: Option<std::sync::Arc<crate::ratelimit::RateLimiter>>,
}
fn real_stack_with_limiter(
db: &DbPool,
limiter: Option<std::sync::Arc<crate::ratelimit::RateLimiter>>,
) -> axum::Router {
let app = real_stack(db);
match limiter {
Some(rl) => app.layer(axum::Extension(rl)),
None => app,
}
}
fn real_stack(db: &DbPool) -> axum::Router {
let manager = crate::auth::create_key_manager().unwrap();
let auth_state = crate::auth::AuthState {
db: db.clone(),
manager: manager.clone(),
public_url: "https://example.com".into(),
required: true,
};
with_client_ip_test_layers(crate::api::router(db.clone(), &[]), test_peer())
.layer(axum::Extension(crate::realtime::RealtimeHub::new()))
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::Extension(std::sync::Arc::new(manager)))
.layer(axum::middleware::from_fn_with_state(
auth_state,
crate::auth::require_api_key,
))
}
fn insert_oauth_token(db: &DbPool, suffix: &str, user_id: i64) -> String {
let token = format!("lific_at_{suffix}");
let hash = crate::auth::sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let conn = db.write().unwrap();
conn.execute(
"INSERT OR IGNORE INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES ('test-client', 'Test', '[\"http://localhost\"]')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id)
VALUES (?1, 'test-client', ?2, 'mcp', ?3)",
params![hash, expires, user_id],
)
.unwrap();
token
}
fn fixture() -> Fixture {
fixture_inner(None)
}
fn fixture_with_limiter(max: usize) -> Fixture {
fixture_inner(Some(std::sync::Arc::new(
crate::ratelimit::RateLimiter::new(max, std::time::Duration::from_secs(15 * 60)),
)))
}
fn fixture_inner(
limiter: Option<std::sync::Arc<crate::ratelimit::RateLimiter>>,
) -> Fixture {
let db = crate::db::open_memory().unwrap();
let manager = crate::auth::create_key_manager().unwrap();
let (user_id, bot_id, stranger_id, session) = {
let conn = db.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
allow_signup: Some(false),
..Default::default()
},
)
.unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "owner".into(),
email: "owner@test.local".into(),
password: PASSWORD.into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let bot = crate::db::queries::users::create_bot_user(
&conn,
user.id,
"opencode-owner",
"OpenCode",
Some("opencode"),
)
.unwrap();
let stranger = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "stranger".into(),
email: "stranger@test.local".into(),
password: PASSWORD.into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let session =
crate::db::queries::users::create_session(&conn, user.id, None).unwrap();
(user.id, bot.id, stranger.id, session.token)
};
let key = |name: &str, owner: Option<i64>| {
crate::auth::create_api_key(&db, &manager, name, owner).unwrap()
};
Fixture {
app: real_stack_with_limiter(&db, limiter.clone()),
limiter,
human_key: key("human", Some(user_id)),
bot_key: key("bot", Some(bot_id)),
stranger_key: key("stranger", Some(stranger_id)),
operator_key: key("operator", None),
human_oauth: insert_oauth_token(&db, "human", user_id),
bot_oauth: insert_oauth_token(&db, "bot", bot_id),
db,
user_id,
bot_id,
stranger_id,
session,
}
}
async fn status_with(app: &axum::Router, token: &str) -> StatusCode {
app.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
async fn send(
app: &axum::Router,
method: &str,
uri: &str,
token: &str,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
let builder = Request::builder()
.method(method)
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json");
let request = match &body {
Some(value) => builder
.body(axum::body::Body::from(serde_json::to_vec(value).unwrap()))
.unwrap(),
None => builder.body(axum::body::Body::empty()).unwrap(),
};
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let parsed = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, parsed)
}
async fn assert_blast_radius(f: &Fixture, replacement: Option<&str>) {
assert_eq!(
status_with(&f.app, &f.session).await,
StatusCode::UNAUTHORIZED,
"the session that made the request is gone too"
);
assert_eq!(
status_with(&f.app, &f.human_key).await,
StatusCode::UNAUTHORIZED,
"the human's API key is revoked"
);
assert_eq!(
status_with(&f.app, &f.bot_key).await,
StatusCode::UNAUTHORIZED,
"an owned bot's API key is revoked"
);
assert_eq!(
status_with(&f.app, &f.human_oauth).await,
StatusCode::UNAUTHORIZED,
"the human's OAuth token is revoked"
);
assert_eq!(
status_with(&f.app, &f.bot_oauth).await,
StatusCode::UNAUTHORIZED,
"an owned bot's OAuth token is revoked"
);
assert_eq!(
status_with(&f.app, &f.stranger_key).await,
StatusCode::OK,
"another account's key is untouched"
);
assert_eq!(
status_with(&f.app, &f.operator_key).await,
StatusCode::OK,
"the unbound operator key names nobody and survives"
);
if let Some(token) = replacement {
assert_eq!(
status_with(&f.app, token).await,
StatusCode::OK,
"the replacement session works on the next request"
);
}
}
#[tokio::test]
async fn password_change_locks_down_everything_and_returns_one_replacement_session() {
let f = fixture();
let (status, body) = send(
&f.app,
"POST",
"/api/auth/me/password",
&f.session,
Some(serde_json::json!({
"current_password": PASSWORD,
"new_password": "a whole new password",
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let replacement = body["token"].as_str().unwrap().to_string();
assert_ne!(replacement, f.session);
assert_blast_radius(&f, Some(&replacement)).await;
let sessions: i64 =
f.db.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?1",
params![f.user_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(sessions, 1, "exactly one replacement session is minted");
}
#[tokio::test]
async fn a_password_change_that_lost_a_race_does_not_overwrite_the_winner() {
let f = fixture();
let captured = {
let conn = f.db.read().unwrap();
crate::db::queries::users::get_user_by_id(&conn, f.user_id)
.unwrap()
.password_hash
};
{
let conn = f.db.write().unwrap();
crate::db::queries::users::update_password(&conn, f.user_id, "winner password")
.unwrap();
}
let winner_hash = {
let conn = f.db.read().unwrap();
crate::db::queries::users::get_user_by_id(&conn, f.user_id)
.unwrap()
.password_hash
};
let (status, body) = send(
&f.app,
"POST",
"/api/auth/me/password",
&f.session,
Some(serde_json::json!({
"current_password": PASSWORD,
"new_password": "loser password",
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
assert_eq!(body["error"], "current password is incorrect");
let stored = {
let conn = f.db.read().unwrap();
crate::db::queries::users::get_user_by_id(&conn, f.user_id)
.unwrap()
.password_hash
};
assert_eq!(stored, winner_hash, "the winner's password survived");
assert_ne!(stored, captured);
}
#[tokio::test]
async fn a_new_password_that_fails_policy_changes_nothing() {
let f = fixture();
for candidate in ["short", &"x".repeat(1025)] {
let (status, _) = send(
&f.app,
"POST",
"/api/auth/me/password",
&f.session,
Some(serde_json::json!({
"current_password": PASSWORD,
"new_password": candidate,
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{candidate:?}");
}
assert_eq!(status_with(&f.app, &f.session).await, StatusCode::OK);
assert_eq!(status_with(&f.app, &f.human_key).await, StatusCode::OK);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn the_writer_is_not_held_while_passwords_are_hashed() {
let f = fixture();
let db = f.db.clone();
let writer = db.write().unwrap();
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(10),
send(
&f.app,
"POST",
"/api/auth/me/password",
&f.session,
Some(serde_json::json!({
"current_password": "definitely wrong",
"new_password": "a whole new password",
})),
),
)
.await;
drop(writer);
let (status, body) = outcome.expect("a wrong password must answer without the writer");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "current password is incorrect");
}
#[tokio::test]
async fn a_wrong_current_password_changes_nothing() {
let f = fixture();
let (status, _) = send(
&f.app,
"POST",
"/api/auth/me/password",
&f.session,
Some(serde_json::json!({
"current_password": "not it",
"new_password": "a whole new password",
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(status_with(&f.app, &f.session).await, StatusCode::OK);
assert_eq!(status_with(&f.app, &f.human_key).await, StatusCode::OK);
assert_eq!(status_with(&f.app, &f.bot_key).await, StatusCode::OK);
}
#[tokio::test]
async fn sign_out_everywhere_locks_down_everything_with_no_replacement() {
let f = fixture();
let (status, _) =
send(&f.app, "DELETE", "/api/auth/me/sessions", &f.session, None).await;
assert_eq!(status, StatusCode::OK);
assert_blast_radius(&f, None).await;
let sessions: i64 =
f.db.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?1",
params![f.user_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(sessions, 0, "sign-out-everywhere mints nothing");
}
#[tokio::test]
async fn operator_reset_carries_the_same_blast_radius() {
let f = fixture();
{
let conn = f.db.write().unwrap();
let user = crate::db::queries::users::get_user_by_username(&conn, "owner").unwrap();
crate::db::queries::savepoint(&conn, "cli_set_password", || {
crate::db::queries::users::update_password(
&conn,
user.id,
"reset by operator",
)?;
crate::db::queries::users::lock_down_account(&conn, user.id)
})
.unwrap();
}
assert_blast_radius(&f, None).await;
let conn = f.db.read().unwrap();
let user = crate::db::queries::users::get_user_by_username(&conn, "owner").unwrap();
assert!(
crate::db::queries::users::verify_password(
"reset by operator",
&user.password_hash
)
.unwrap()
);
}
#[tokio::test]
async fn a_recent_session_may_mint_a_key_and_connect_a_tool() {
let f = fixture();
let (status, body) = send(
&f.app,
"POST",
"/api/auth/keys",
&f.session,
Some(serde_json::json!({"name": "laptop"})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["key"].as_str().unwrap().starts_with("lific_sk"));
assert_eq!(
status_with(&f.app, body["key"].as_str().unwrap()).await,
StatusCode::OK
);
let (status, body) = send(
&f.app,
"POST",
"/api/auth/bots",
&f.session,
Some(serde_json::json!({"tool": "zed"})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["key"].as_str().unwrap().starts_with("lific_sk"));
}
#[tokio::test]
async fn an_aged_session_may_not_mint_credentials() {
let f = fixture();
f.db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
for (uri, body) in [
("/api/auth/keys", serde_json::json!({"name": "laptop"})),
("/api/auth/bots", serde_json::json!({"tool": "zed"})),
] {
let (status, _) = send(&f.app, "POST", uri, &f.session, Some(body)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{uri}");
}
assert_eq!(status_with(&f.app, &f.session).await, StatusCode::OK);
}
#[tokio::test]
async fn an_api_key_may_no_longer_mint_credentials() {
let f = fixture();
assert_eq!(status_with(&f.app, &f.human_key).await, StatusCode::OK);
for (uri, body) in [
(
"/api/auth/keys",
serde_json::json!({"name": "minted-by-key"}),
),
("/api/auth/bots", serde_json::json!({"tool": "zed"})),
] {
let (status, _) = send(&f.app, "POST", uri, &f.human_key, Some(body)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{uri}");
}
let minted: i64 =
f.db.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM api_keys WHERE name = 'minted-by-key'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(minted, 0, "a refused mint writes nothing");
}
#[tokio::test]
async fn an_oauth_token_may_not_mint_credentials() {
let f = fixture();
for (uri, body) in [
(
"/api/auth/keys",
serde_json::json!({"name": "minted-by-oauth"}),
),
("/api/auth/bots", serde_json::json!({"tool": "zed"})),
] {
let (status, _) = send(&f.app, "POST", uri, &f.human_oauth, Some(body)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{uri}");
}
}
#[tokio::test]
async fn a_revoked_session_may_not_mint_credentials() {
let f = fixture();
f.db.write()
.unwrap()
.execute("DELETE FROM sessions", [])
.unwrap();
let (status, _) = send(
&f.app,
"POST",
"/api/auth/keys",
&f.session,
Some(serde_json::json!({"name": "ghost"})),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn a_key_minted_immediately_before_a_lockdown_does_not_survive_it() {
let f = fixture();
let (status, body) = send(
&f.app,
"POST",
"/api/auth/keys",
&f.session,
Some(serde_json::json!({"name": "racing"})),
)
.await;
assert_eq!(status, StatusCode::OK);
let racing_key = body["key"].as_str().unwrap().to_string();
assert_eq!(status_with(&f.app, &racing_key).await, StatusCode::OK);
{
let conn = f.db.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, f.user_id).unwrap();
}
assert_eq!(
status_with(&f.app, &racing_key).await,
StatusCode::UNAUTHORIZED,
"a key created just before the lockdown is inside its blast radius"
);
}
#[tokio::test]
async fn a_tool_can_be_reconnected_after_a_lockdown() {
let f = fixture();
let (status, body) = send(
&f.app,
"POST",
"/api/auth/me/password",
&f.session,
Some(serde_json::json!({
"current_password": PASSWORD,
"new_password": "a whole new password",
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let replacement = body["token"].as_str().unwrap().to_string();
let (status, body) = send(
&f.app,
"POST",
"/api/auth/bots",
&replacement,
Some(serde_json::json!({"tool": "opencode"})),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["bot"]["id"].as_i64(),
Some(f.bot_id),
"the same bot identity is reused, not a duplicate"
);
let new_key = body["key"].as_str().unwrap();
assert_eq!(status_with(&f.app, new_key).await, StatusCode::OK);
assert_eq!(
status_with(&f.app, &f.bot_key).await,
StatusCode::UNAUTHORIZED,
"the pre-lockdown key stays dead"
);
let conn = f.db.read().unwrap();
let live: Vec<(String, Option<i64>)> = conn
.prepare("SELECT name, user_id FROM api_keys WHERE revoked = 0 AND user_id = ?1")
.unwrap()
.query_map(params![f.bot_id], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(live, vec![("opencode-owner".to_string(), Some(f.bot_id))]);
}
struct Expansions {
create: StatusCode,
promote: StatusCode,
reactivate: StatusCode,
settings: StatusCode,
}
async fn roster_attempt(f: &Fixture, token: &str) -> Expansions {
let (create, _) = send(
&f.app,
"POST",
"/api/users",
token,
Some(serde_json::json!({
"username": "smuggled-admin",
"password": "securepass123",
"is_admin": true,
})),
)
.await;
let (promote, _) = send(
&f.app,
"POST",
&format!("/api/users/{}/promote", f.stranger_id),
token,
Some(serde_json::json!({})),
)
.await;
let _ = send(
&f.app,
"POST",
&format!("/api/users/{}/deactivate", f.stranger_id),
&f.operator_key,
Some(serde_json::json!({})),
)
.await;
let (reactivate, _) = send(
&f.app,
"POST",
&format!("/api/users/{}/reactivate", f.stranger_id),
token,
Some(serde_json::json!({})),
)
.await;
let (settings, _) = send(
&f.app,
"PATCH",
"/api/instance/settings",
token,
Some(serde_json::json!({ "allow_signup": true })),
)
.await;
Expansions {
create,
promote,
reactivate,
settings,
}
}
fn signup_is_open(f: &Fixture) -> bool {
crate::db::queries::settings::get(&f.db.read().unwrap())
.unwrap()
.allow_signup
}
fn is_active(f: &Fixture, id: i64) -> bool {
crate::db::queries::users::get_user_by_id(&f.db.read().unwrap(), id)
.unwrap()
.is_active
}
fn assert_nothing_expanded(f: &Fixture, attempt: &Expansions, expected: StatusCode) {
assert_eq!(attempt.create, expected, "create");
assert_eq!(attempt.promote, expected, "promote");
assert_eq!(attempt.reactivate, expected, "reactivate");
assert_eq!(attempt.settings, expected, "instance settings");
assert!(!smuggled_exists(f), "no account was created");
assert!(!is_admin(f, f.stranger_id), "no admin flag was written");
assert!(!is_active(f, f.stranger_id), "the account stayed off");
assert!(!signup_is_open(f), "settings were not widened");
}
fn smuggled_exists(f: &Fixture) -> bool {
crate::db::queries::users::get_user_by_username(&f.db.read().unwrap(), "smuggled-admin")
.is_ok()
}
fn is_admin(f: &Fixture, id: i64) -> bool {
crate::db::queries::users::get_user_by_id(&f.db.read().unwrap(), id)
.unwrap()
.is_admin
}
#[tokio::test]
async fn a_recent_session_may_perform_every_expansion() {
let f = fixture();
let attempt = roster_attempt(&f, &f.session).await;
assert_eq!(attempt.create, StatusCode::OK);
assert_eq!(attempt.promote, StatusCode::OK);
assert_eq!(attempt.reactivate, StatusCode::OK);
assert_eq!(attempt.settings, StatusCode::OK);
assert!(smuggled_exists(&f));
assert!(is_admin(&f, f.stranger_id));
assert!(is_active(&f, f.stranger_id));
assert!(signup_is_open(&f));
}
#[tokio::test]
async fn an_aged_session_may_perform_no_expansion() {
let f = fixture();
f.db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
let attempt = roster_attempt(&f, &f.session).await;
assert_nothing_expanded(&f, &attempt, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_admin_api_key_may_perform_no_expansion() {
let f = fixture();
assert_eq!(status_with(&f.app, &f.human_key).await, StatusCode::OK);
let attempt = roster_attempt(&f, &f.human_key).await;
assert_nothing_expanded(&f, &attempt, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_oauth_token_may_perform_no_expansion() {
let f = fixture();
let attempt = roster_attempt(&f, &f.human_oauth).await;
assert_nothing_expanded(&f, &attempt, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_session_revoked_by_a_lockdown_may_perform_no_expansion() {
let f = fixture();
{
let conn = f.db.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, f.user_id).unwrap();
}
let attempt = roster_attempt(&f, &f.session).await;
assert_nothing_expanded(&f, &attempt, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn reducing_roster_actions_do_not_require_recent_authentication() {
let f = fixture();
f.db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
for action in ["demote", "deactivate"] {
let (status, body) = send(
&f.app,
"POST",
&format!("/api/users/{}/{action}", f.stranger_id),
&f.session,
Some(serde_json::json!({})),
)
.await;
assert_eq!(status, StatusCode::OK, "{action}: {body}");
}
}
async fn refresh(
f: &Fixture,
token: &str,
password: Option<&str>,
) -> (StatusCode, serde_json::Value) {
send(
&f.app,
"POST",
"/api/auth/me/refresh",
token,
Some(match password {
Some(p) => serde_json::json!({ "password": p }),
None => serde_json::json!({}),
}),
)
.await
}
fn session_count(f: &Fixture, user_id: i64) -> i64 {
f.db.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?1",
params![user_id],
|r| r.get(0),
)
.unwrap()
}
#[tokio::test]
async fn a_correct_password_swaps_this_session_for_a_fresh_one() {
let f = fixture();
let other = {
let conn = f.db.write().unwrap();
crate::db::queries::users::create_session(&conn, f.user_id, None)
.unwrap()
.token
};
f.db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
let (status, body) = refresh(&f, &f.session, Some(PASSWORD)).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["user"]["id"].as_i64(),
Some(f.user_id),
"the refreshed session is for the same account"
);
let replacement = body["token"].as_str().unwrap().to_string();
assert_ne!(replacement, f.session);
assert_eq!(
status_with(&f.app, &f.session).await,
StatusCode::UNAUTHORIZED,
"the presented session was consumed"
);
assert_eq!(
status_with(&f.app, &other).await,
StatusCode::OK,
"every other session survives"
);
let (status, _) = send(
&f.app,
"POST",
"/api/auth/keys",
&replacement,
Some(serde_json::json!({"name": "after-refresh"})),
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_wrong_password_leaves_the_old_session_working() {
let f = fixture();
let before = session_count(&f, f.user_id);
let (status, _) = refresh(&f, &f.session, Some("not the password")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(status_with(&f.app, &f.session).await, StatusCode::OK);
assert_eq!(session_count(&f, f.user_id), before, "nothing was minted");
}
#[tokio::test]
async fn a_password_change_between_verify_and_finalize_is_refused() {
let f = fixture();
let stale_hash = {
let conn = f.db.read().unwrap();
crate::db::queries::users::get_user_by_id(&conn, f.user_id)
.unwrap()
.password_hash
};
{
let conn = f.db.write().unwrap();
crate::db::queries::users::update_password(&conn, f.user_id, "a whole new one")
.unwrap();
let refreshed =
crate::db::queries::users::get_user_by_id(&conn, f.user_id).unwrap();
assert_ne!(refreshed.password_hash, stale_hash);
assert!(
crate::db::queries::users::finalize_login(&conn, f.user_id, &stale_hash)
.is_err(),
"a session must not be minted against a superseded hash"
);
}
let (status, _) = refresh(&f, &f.session, Some(PASSWORD)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(session_count(&f, f.user_id), 1, "no replacement was minted");
}
#[tokio::test]
async fn a_password_is_required_when_the_instance_uses_passwords() {
let f = fixture();
let (status, body) = refresh(&f, &f.session, None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("password is required"),
"{body}"
);
}
#[tokio::test]
async fn a_passwordless_instance_refreshes_the_caller_not_the_first_admin() {
let f = fixture();
let (second_id, second_session) = {
let conn = f.db.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
web_auto_login: Some(true),
..Default::default()
},
)
.unwrap();
let second = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "second-admin".into(),
email: "second@test.local".into(),
password: PASSWORD.into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let session =
crate::db::queries::users::create_session(&conn, second.id, None).unwrap();
(second.id, session.token)
};
assert_ne!(second_id, f.user_id);
let (status, body) = refresh(&f, &second_session, None).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["user"]["id"].as_i64(),
Some(second_id),
"passwordless refresh must never hand back the first admin"
);
}
#[tokio::test]
async fn turning_passwordless_off_before_the_write_refuses_the_refresh() {
let f = fixture();
let (status, _) = refresh(&f, &f.session, None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(session_count(&f, f.user_id), 1);
}
#[tokio::test]
async fn api_keys_and_oauth_tokens_may_not_refresh_a_session() {
let f = fixture();
for token in [&f.human_key, &f.human_oauth] {
let (status, _) = refresh(&f, token, Some(PASSWORD)).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
assert_eq!(session_count(&f, f.user_id), 1, "nothing was minted");
}
#[tokio::test]
async fn a_revoked_session_may_not_refresh_itself() {
let f = fixture();
{
let conn = f.db.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, f.user_id).unwrap();
}
let (status, _) = refresh(&f, &f.session, Some(PASSWORD)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(session_count(&f, f.user_id), 0);
}
async fn change_password_attempt(
f: &Fixture,
token: &str,
current: &str,
new: &str,
) -> (StatusCode, serde_json::Value) {
send(
&f.app,
"POST",
"/api/auth/me/password",
token,
Some(serde_json::json!({
"current_password": current,
"new_password": new,
})),
)
.await
}
#[tokio::test]
async fn repeated_wrong_current_passwords_exhaust_the_change_budget() {
let f = fixture_with_limiter(2);
for attempt in 0..2 {
let (status, body) =
change_password_attempt(&f, &f.session, "wrong", "a whole new password").await;
assert_eq!(status, StatusCode::BAD_REQUEST, "attempt {attempt}");
assert_eq!(body["error"], "current password is incorrect");
}
let (status, body) =
change_password_attempt(&f, &f.session, "wrong", "a whole new password").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("too many password change attempts"),
"{body}"
);
let (_, body) =
change_password_attempt(&f, &f.session, PASSWORD, "a whole new password").await;
assert!(
body["error"]
.as_str()
.unwrap()
.contains("too many password change attempts"),
"{body}"
);
}
#[tokio::test]
async fn a_rejected_new_password_still_spends_its_reservation() {
let f = fixture_with_limiter(1);
let limiter = f.limiter.clone().expect("limiter");
let (status, _) = change_password_attempt(&f, &f.session, PASSWORD, "short").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
limiter.contains_key(&format!("password_change_user:{}", f.user_id)),
"the attempt was spent"
);
}
#[tokio::test]
async fn successful_password_changes_refund_their_reservations() {
let f = fixture_with_limiter(1);
let limiter = f.limiter.clone().expect("limiter");
let mut token = f.session.clone();
let mut current = PASSWORD.to_string();
for round in 0..3 {
let next = format!("password number {round}");
let (status, body) = change_password_attempt(&f, &token, ¤t, &next).await;
assert_eq!(status, StatusCode::OK, "round {round}: {body}");
token = body["token"].as_str().unwrap().to_string();
current = next;
}
assert!(
!limiter.contains_key(&format!("password_change_user:{}", f.user_id)),
"a clean run of password changes leaves no budget spent"
);
}
#[tokio::test]
async fn reservations_bound_how_many_password_changes_are_admitted() {
let f = fixture_with_limiter(2);
let limiter = f.limiter.clone().expect("limiter");
let held: [_; 2] = std::array::from_fn(|i| {
crate::ratelimit::Reservation::acquire(
&limiter,
&format!("password_change_ip:10.0.0.{i}"),
&format!("password_change_user:{}", f.user_id),
)
.expect("within budget")
});
let (status, body) =
change_password_attempt(&f, &f.session, PASSWORD, "a whole new password").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("too many password change attempts"),
"{body}"
);
assert!(
body["error"].as_str().unwrap().contains("try again in"),
"the refusal keeps its retry guidance: {body}"
);
held.into_iter().next().unwrap().refund();
let (status, _) =
change_password_attempt(&f, &f.session, PASSWORD, "a whole new password").await;
assert_eq!(status, StatusCode::OK, "the original password still works");
}
#[tokio::test]
async fn repeated_wrong_passwords_exhaust_the_confirmation_budget() {
let f = fixture_with_limiter(2);
for attempt in 0..2 {
let (status, body) = refresh(&f, &f.session, Some("wrong")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "incorrect password", "attempt {attempt}");
}
let (status, body) = refresh(&f, &f.session, Some("wrong")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("too many confirmation attempts"),
"{body}"
);
let (_, body) = refresh(&f, &f.session, Some(PASSWORD)).await;
assert!(
body["error"]
.as_str()
.unwrap()
.contains("too many confirmation attempts"),
"{body}"
);
}
#[tokio::test]
async fn a_successful_confirmation_refunds_its_reservation() {
let f = fixture_with_limiter(2);
let limiter = f.limiter.clone().expect("limiter");
let mut token = f.session.clone();
for attempt in 0..4 {
let (status, body) = refresh(&f, &token, Some(PASSWORD)).await;
assert_eq!(status, StatusCode::OK, "attempt {attempt}: {body}");
token = body["token"].as_str().unwrap().to_string();
}
assert!(
!limiter.contains_key(&format!("reauth_user:{}", f.user_id)),
"successful confirmations leave no budget spent"
);
}
#[tokio::test]
async fn the_confirmation_budget_is_scoped_to_the_account() {
let f = fixture_with_limiter(1);
let limiter = f.limiter.clone().expect("limiter");
let (_, body) = refresh(&f, &f.session, Some("wrong")).await;
assert_eq!(body["error"], "incorrect password");
assert!(limiter.contains_key(&format!("reauth_user:{}", f.user_id)));
assert!(
!limiter.contains_key(&format!("reauth_user:{}", f.stranger_id)),
"another account's budget is untouched"
);
assert!(
!limiter.contains_key("login_id:owner"),
"confirming must not drain the account's login budget"
);
}
#[tokio::test]
async fn a_passwordless_confirmation_needs_no_reservation() {
let f = fixture_with_limiter(1);
let limiter = f.limiter.clone().expect("limiter");
f.db.write()
.unwrap()
.execute("UPDATE instance_settings SET web_auto_login = 1", [])
.unwrap();
let mut token = f.session.clone();
for attempt in 0..3 {
let (status, body) = refresh(&f, &token, None).await;
assert_eq!(status, StatusCode::OK, "attempt {attempt}: {body}");
token = body["token"].as_str().unwrap().to_string();
}
assert!(!limiter.contains_key(&format!("reauth_user:{}", f.user_id)));
}
#[tokio::test]
async fn an_admin_demoted_after_the_request_arrived_may_not_expand() {
let f = fixture();
{
let conn = f.db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "other-admin".into(),
email: "other@test.local".into(),
password: PASSWORD.into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
crate::db::queries::users::set_admin_guarded(&conn, f.user_id, false).unwrap();
}
let attempt = roster_attempt(&f, &f.session).await;
assert_eq!(attempt.create, StatusCode::FORBIDDEN);
assert_eq!(attempt.promote, StatusCode::FORBIDDEN);
assert_eq!(attempt.reactivate, StatusCode::FORBIDDEN);
assert_eq!(attempt.settings, StatusCode::FORBIDDEN);
assert!(!smuggled_exists(&f));
assert!(!signup_is_open(&f));
}
#[tokio::test]
async fn a_failed_tool_connect_leaves_no_half_created_identity() {
let f = fixture();
let before: i64 =
f.db.read()
.unwrap()
.query_row("SELECT COUNT(*) FROM users WHERE is_bot = 1", [], |r| {
r.get(0)
})
.unwrap();
let (status, _) = send(
&f.app,
"POST",
"/api/auth/bots",
&f.session,
Some(serde_json::json!({"tool": "opencode"})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let conn = f.db.read().unwrap();
let after: i64 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_bot = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(before, after);
let keys: i64 = conn
.query_row(
"SELECT COUNT(*) FROM api_keys WHERE user_id = ?1 AND revoked = 0",
params![f.bot_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(keys, 1, "no second key was minted for the connected bot");
drop(conn);
assert_eq!(
status_with(&f.app, &f.bot_key).await,
StatusCode::OK,
"the refusal did not rotate the tool's live credential out from under it"
);
}
}
#[test]
fn session_cookie_gates_secure_flag() {
let secure = super::session_cookie("lific_sess_x", "2099-01-01T00:00:00Z", true);
assert!(secure.contains("; Secure"));
assert!(secure.contains("HttpOnly"));
assert!(secure.contains("SameSite=Lax"));
let insecure = super::session_cookie("lific_sess_x", "2099-01-01T00:00:00Z", false);
assert!(
!insecure.contains("Secure"),
"http deploy must omit Secure: {insecure}"
);
assert!(insecure.contains("HttpOnly"));
assert!(insecure.contains("SameSite=Lax"));
}
#[test]
fn clear_cookie_mirrors_secure_flag() {
assert!(super::clear_cookie(true).contains("; Secure"));
assert!(!super::clear_cookie(false).contains("Secure"));
assert!(super::clear_cookie(true).contains("Max-Age=0"));
}
#[tokio::test]
async fn auth_signup_creates_user_and_returns_session() {
let app = test_app();
let body = serde_json::json!({
"username": "blake",
"email": "blake@test.com",
"password": "securepass123"
});
let resp = json_post(&app, "/api/auth/signup", body).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["user"]["username"], "blake");
assert!(data["token"].as_str().unwrap().starts_with("lific_sess_"));
assert!(data["expires_at"].as_str().is_some());
}
fn zero_user_app(db: crate::db::DbPool) -> axum::Router {
with_client_ip_test_layers(
with_attachment_layers(crate::api::router(db, &[])),
test_peer(),
)
.layer(axum::Extension(crate::realtime::RealtimeHub::new()))
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: false,
secure_cookies: false,
}))
}
#[tokio::test]
async fn user_roster_handler_refuses_without_an_identity() {
let app = zero_user_app(crate::db::open_memory().expect("test db")).layer(axum::Extension(
None::<crate::resolve_caller::ResolvedIdentity>,
));
let resp = json_get(&app, "/api/users").await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn first_signup_bootstraps_admin_second_does_not() {
let app = zero_user_app(crate::db::open_memory().expect("test db"));
let resp = json_post(
&app,
"/api/auth/signup",
serde_json::json!({
"username": "operator",
"email": "op@test.com",
"password": "securepass123"
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["user"]["is_admin"], true, "first user is the admin");
let resp = json_post(
&app,
"/api/auth/signup",
serde_json::json!({
"username": "agent",
"email": "agent@test.com",
"password": "securepass123"
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["user"]["is_admin"], false, "second user is plain");
}
#[tokio::test]
async fn signup_after_cli_user_gets_no_admin() {
let db = crate::db::open_memory().expect("test db");
{
let conn = db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "clifirst".into(),
email: "cli@test.com".into(),
password: "securepass123".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
}
let app = zero_user_app(db);
let resp = json_post(
&app,
"/api/auth/signup",
serde_json::json!({
"username": "weblater",
"email": "web@test.com",
"password": "securepass123"
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["user"]["is_admin"], false);
}
#[tokio::test]
async fn auth_signup_duplicate_rejected() {
let app = test_app();
let body = serde_json::json!({
"username": "dupe",
"email": "dupe@test.com",
"password": "securepass123"
});
let resp = json_post(&app, "/api/auth/signup", body.clone()).await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = json_post(&app, "/api/auth/signup", body).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn auth_signup_disabled_rejects() {
let db = crate::db::open_memory().expect("test db");
{
let conn = db.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
allow_signup: Some(false),
..Default::default()
},
)
.unwrap();
}
let app = with_client_ip_test_layers(crate::api::router(db, &[]), test_peer()).layer(
axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}),
);
let body = serde_json::json!({
"username": "blocked",
"email": "blocked@test.com",
"password": "securepass123"
});
let resp = json_post(&app, "/api/auth/signup", body).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let data = parse_json(resp).await;
assert!(data["error"].as_str().unwrap().contains("closed"));
}
#[tokio::test]
async fn instance_reports_open_signup_and_existing_users() {
let app = test_app();
let resp = json_get(&app, "/api/instance").await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["allow_signup"], true);
assert_eq!(data["has_users"], true, "seeded admin counts as a human");
}
#[tokio::test]
async fn instance_reports_closed_signup_and_empty_when_fresh() {
let db = crate::db::open_memory().expect("test db");
{
let conn = db.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
allow_signup: Some(false),
..Default::default()
},
)
.unwrap();
}
let app = with_client_ip_test_layers(crate::api::router(db, &[]), test_peer()).layer(
axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}),
);
let resp = json_get(&app, "/api/instance").await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["allow_signup"], false);
assert_eq!(data["has_users"], false);
}
#[tokio::test]
async fn instance_flips_has_users_after_first_signup() {
let db = crate::db::open_memory().expect("test db");
let app = with_client_ip_test_layers(crate::api::router(db, &[]), test_peer()).layer(
axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}),
);
let before = parse_json(json_get(&app, "/api/instance").await).await;
assert_eq!(before["has_users"], false);
let body = serde_json::json!({
"username": "firsthuman",
"email": "first@test.com",
"password": "securepass123"
});
assert_eq!(
json_post(&app, "/api/auth/signup", body).await.status(),
StatusCode::OK
);
let after = parse_json(json_get(&app, "/api/instance").await).await;
assert_eq!(after["has_users"], true);
}
#[tokio::test]
async fn instance_settings_admin_can_read_and_patch() {
let app = test_app();
let data = parse_json(json_get(&app, "/api/instance/settings").await).await;
assert_eq!(data["allow_signup"], true);
assert_eq!(data["session_lifetime_days"], 30);
let patch = serde_json::json!({
"instance_name": "Acme Eng",
"allow_signup": false,
"session_lifetime_days": 14,
"signup_email_domains": ["acme.com"],
});
let resp = json_patch(&app, "/api/instance/settings", patch).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["instance_name"], "Acme Eng");
assert_eq!(data["allow_signup"], false);
assert_eq!(data["session_lifetime_days"], 14);
assert_eq!(data["signup_email_domains"][0], "acme.com");
let pub_data = parse_json(json_get(&app, "/api/instance").await).await;
assert_eq!(pub_data["allow_signup"], false);
assert_eq!(pub_data["instance_name"], "Acme Eng");
}
#[tokio::test]
async fn instance_settings_exposes_authz_enforced_toggle() {
let app = test_app();
let data = parse_json(json_get(&app, "/api/instance/settings").await).await;
assert_eq!(data["authz_enforced"], false, "off by default");
let patch = json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "authz_enforced": true }),
)
.await;
assert_eq!(patch.status(), StatusCode::OK);
assert_eq!(parse_json(patch).await["authz_enforced"], true);
let data = parse_json(json_get(&app, "/api/instance/settings").await).await;
assert_eq!(data["authz_enforced"], true, "persisted");
}
#[tokio::test]
async fn changing_authz_enforcement_emits_resync_required() {
let test = test_app_with_realtime();
let mut events = test.realtime.subscribe();
let resp = json_patch(
&test.app,
"/api/instance/settings",
serde_json::json!({ "authz_enforced": true }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let event = tokio::time::timeout(std::time::Duration::from_secs(1), events.recv())
.await
.unwrap()
.unwrap();
let axum::extract::ws::Message::Text(text) = event.message else {
panic!("expected text realtime event");
};
let event: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(event["type"], "resync.required");
}
#[tokio::test]
async fn patching_authz_enforcement_to_its_current_value_emits_nothing() {
let test = test_app_with_realtime();
let mut events = test.realtime.subscribe();
let resp = json_patch(
&test.app,
"/api/instance/settings",
serde_json::json!({ "authz_enforced": false }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(
events.try_recv().is_err(),
"no realtime event should be emitted for a no-op authz patch"
);
}
#[tokio::test]
async fn instance_settings_forbidden_for_non_admin() {
let db = crate::db::open_memory().expect("test db");
let user = {
let conn = db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "reg".into(),
email: "reg@test.com".into(),
password: "securepass123".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
};
let app = crate::api::test_helpers::app_as_user(db, &user);
assert_eq!(
json_get(&app, "/api/instance/settings").await.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "allow_signup": true })
)
.await
.status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn auto_login_disabled_by_default_is_forbidden() {
let app = test_app();
let resp = json_post(&app, "/api/auth/auto-login", serde_json::json!({})).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn auth_optional_instance_reports_auto_login() {
let app = test_app_with_auth(false);
let resp = json_get(&app, "/api/instance").await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(
data["web_auto_login"], true,
"auth-optional instances must advertise auto-login to the web app: {data}"
);
let resp = json_get(&test_app(), "/api/instance").await;
assert_eq!(parse_json(resp).await["web_auto_login"], false);
}
#[tokio::test]
async fn auth_optional_auto_login_mints_admin_session_without_db_flag() {
let app = test_app_with_auth(false); let resp = json_post(&app, "/api/auth/auto-login", serde_json::json!({})).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert!(
data["token"].as_str().unwrap().starts_with("lific_sess_"),
"auto-login must mint a real session under auth-optional: {data}"
);
assert_eq!(data["user"]["username"], "test-admin");
assert_eq!(data["user"]["is_admin"], true);
}
#[tokio::test]
async fn auth_optional_admin_settings_surface_keeps_real_flag() {
let app = test_app_with_auth(false);
let resp = json_get(&app, "/api/instance/settings").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["web_auto_login"], false);
}
#[tokio::test]
async fn auto_login_enabled_mints_admin_session() {
let app = test_app(); let patch = json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "web_auto_login": true }),
)
.await;
assert_eq!(patch.status(), StatusCode::OK);
assert_eq!(parse_json(patch).await["web_auto_login"], true);
let resp = json_post(&app, "/api/auth/auto-login", serde_json::json!({})).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert!(
data["token"].as_str().unwrap().starts_with("lific_sess_"),
"auto-login must mint a real session token: {data}"
);
assert_eq!(data["user"]["is_admin"], true);
assert_eq!(data["user"]["username"], "test-admin");
}
fn app_with_reachability(host: &str, public_url: Option<&str>) -> axum::Router {
test_app().layer(axum::Extension(crate::server::Reachability {
host: host.to_string(),
public_url: public_url.map(str::to_string),
}))
}
#[tokio::test]
async fn enabling_auto_login_is_refused_on_a_publicly_reachable_instance() {
let app = app_with_reachability("127.0.0.1", Some("https://magi.tailb93ac8.ts.net"));
let resp = json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "web_auto_login": true }),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let data = parse_json(resp).await;
assert!(
data["error"]
.as_str()
.unwrap_or("")
.contains("web auto-login cannot be enabled"),
"the refusal must say why: {data}"
);
let stored = parse_json(json_get(&app, "/api/instance/settings").await).await;
assert_eq!(
stored["web_auto_login"], false,
"a refused patch must persist nothing: {stored}"
);
let public = parse_json(json_get(&app, "/api/instance").await).await;
assert_eq!(public["web_auto_login"], false);
}
#[tokio::test]
async fn enabling_auto_login_still_works_on_a_loopback_instance() {
let app = app_with_reachability("127.0.0.1", Some("http://127.0.0.1:3456"));
let resp = json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "web_auto_login": true }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["web_auto_login"], true);
let stored = parse_json(json_get(&app, "/api/instance/settings").await).await;
assert_eq!(stored["web_auto_login"], true, "persisted: {stored}");
let resp = json_post(&app, "/api/auth/auto-login", serde_json::json!({})).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn the_reachability_guard_only_blocks_turning_auto_login_on() {
let app = app_with_reachability("0.0.0.0", None);
let resp = json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "instance_name": "Acme Eng", "web_auto_login": false }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["instance_name"], "Acme Eng");
assert_eq!(data["web_auto_login"], false);
}
#[tokio::test]
async fn instance_info_exposes_web_auto_login() {
let app = test_app();
let before = parse_json(json_get(&app, "/api/instance").await).await;
assert_eq!(before["web_auto_login"], false, "off by default");
json_patch(
&app,
"/api/instance/settings",
serde_json::json!({ "web_auto_login": true }),
)
.await;
let after = parse_json(json_get(&app, "/api/instance").await).await;
assert_eq!(after["web_auto_login"], true);
}
#[tokio::test]
async fn signup_enforces_email_domain_allowlist() {
let db = crate::db::open_memory().expect("test db");
{
let conn = db.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
signup_email_domains: Some(vec!["acme.com".into()]),
..Default::default()
},
)
.unwrap();
}
let app = with_client_ip_test_layers(crate::api::router(db, &[]), test_peer()).layer(
axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}),
);
let resp = json_post(
&app,
"/api/auth/signup",
serde_json::json!({ "username": "x", "email": "x@other.com", "password": "securepass123" }),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = json_post(
&app,
"/api/auth/signup",
serde_json::json!({ "username": "y", "email": "y@acme.com", "password": "securepass123" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn auth_login_with_correct_password() {
let app = test_app();
let body = serde_json::json!({
"username": "logintest",
"email": "login@test.com",
"password": "securepass123"
});
json_post(&app, "/api/auth/signup", body).await;
let body = serde_json::json!({
"identity": "logintest",
"password": "securepass123"
});
let resp = json_post(&app, "/api/auth/login", body).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["user"]["username"], "logintest");
assert!(data["token"].as_str().unwrap().starts_with("lific_sess_"));
}
#[tokio::test]
async fn auth_login_with_wrong_password() {
let app = test_app();
let body = serde_json::json!({
"username": "wrongpw",
"email": "wrongpw@test.com",
"password": "securepass123"
});
json_post(&app, "/api/auth/signup", body).await;
let body = serde_json::json!({
"identity": "wrongpw",
"password": "nope12345678"
});
let resp = json_post(&app, "/api/auth/login", body).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
fn login_app(db: crate::db::DbPool) -> axum::Router {
with_client_ip_test_layers(crate::api::router(db, &[]), test_peer()).layer(axum::Extension(
crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
},
))
}
async fn login(
app: &axum::Router,
identity: &str,
password: &str,
) -> (StatusCode, serde_json::Value) {
let resp = json_post(
app,
"/api/auth/login",
serde_json::json!({ "identity": identity, "password": password }),
)
.await;
let status = resp.status();
(status, parse_json(resp).await)
}
#[tokio::test]
async fn login_answers_correctly_through_the_offloaded_verify() {
let app = login_app(crate::db::open_memory().expect("test db"));
let signup = serde_json::json!({
"username": "offloaded",
"email": "offloaded@test.com",
"password": "securepass123",
});
assert_eq!(
json_post(&app, "/api/auth/signup", signup).await.status(),
StatusCode::OK,
"signup hashes off the writer too"
);
let (status, data) = login(&app, "offloaded", "securepass123").await;
assert_eq!(status, StatusCode::OK, "correct password: {data}");
assert_eq!(data["user"]["username"], "offloaded");
assert!(data["token"].as_str().unwrap().starts_with("lific_sess_"));
let (status, data) = login(&app, "offloaded@test.com", "securepass123").await;
assert_eq!(status, StatusCode::OK, "login by email: {data}");
let (status, wrong) = login(&app, "offloaded", "not-the-password").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, unknown) = login(&app, "no-such-user", "not-the-password").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
wrong["error"], unknown["error"],
"a wrong password and an unknown account must be indistinguishable"
);
let (status, oversized) = login(&app, "offloaded", &"x".repeat(2000)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
oversized["error"], wrong["error"],
"an oversized password is refused with the same opaque message"
);
}
#[tokio::test]
async fn login_still_reports_a_deactivated_account() {
let db = crate::db::open_memory().expect("test db");
{
let conn = db.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "switched-off".into(),
email: "off@test.com".into(),
password: "securepass123".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
crate::db::queries::users::set_active(&conn, user.id, false).unwrap();
}
let app = login_app(db);
let (status, data) = login(&app, "switched-off", "securepass123").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
data["error"].as_str().unwrap_or("").contains("deactivated"),
"the deactivated-account message must survive the offload: {data}"
);
}
#[tokio::test]
async fn auth_me_with_session() {
let app = test_app();
let body = serde_json::json!({
"username": "metest",
"email": "me@test.com",
"password": "securepass123"
});
let resp = json_post(&app, "/api/auth/signup", body).await;
let data = parse_json(resp).await;
let token = data["token"].as_str().unwrap();
assert_eq!(data["user"]["username"], "metest");
assert!(token.starts_with("lific_sess_"));
}
#[tokio::test]
async fn auth_me_without_identity_is_403_authentication_required() {
use tower::ServiceExt;
let db = crate::db::open_memory().expect("test db");
let app = with_client_ip_test_layers(crate::api::router(db, &[]), test_peer())
.layer(axum::Extension(crate::realtime::RealtimeHub::new()))
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::Extension(None::<crate::db::models::AuthUser>))
.layer(axum::Extension(
None::<crate::resolve_caller::ResolvedIdentity>,
));
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/api/auth/me")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let data = parse_json(resp).await;
assert_eq!(
data["error"], "authentication required",
"auth_me must use the shared require_user gate's error: {data}"
);
}
#[tokio::test]
async fn update_me_changes_display_name() {
use tower::ServiceExt;
let app = test_app();
let body = serde_json::json!({ "display_name": "Renamed Admin" });
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("PATCH")
.uri("/api/auth/me")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["display_name"], "Renamed Admin");
}
#[tokio::test]
async fn change_password_requires_correct_current() {
let db = crate::db::open_memory().expect("test db");
let user = {
let conn = db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "pwuser".into(),
email: "pwuser@test.com".into(),
password: "originalpass123".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
};
let app = crate::api::test_helpers::app_as_user(db, &user);
let wrong = serde_json::json!({ "current_password": "totally-wrong", "new_password": "newpassword123" });
let resp = json_post(&app, "/api/auth/me/password", wrong).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let right = serde_json::json!({ "current_password": "originalpass123", "new_password": "newpassword123" });
let resp = json_post(&app, "/api/auth/me/password", right).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert!(
data["token"]
.as_str()
.unwrap_or("")
.starts_with("lific_sess_"),
"password change should mint a new session token: {data}"
);
assert!(data["expires_at"].as_str().is_some());
}
#[tokio::test]
async fn change_password_invalidates_existing_sessions() {
use crate::db::queries::users;
let db = crate::db::open_memory().expect("test db");
let user = {
let conn = db.write().unwrap();
users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "rotate".into(),
email: "rotate@test.com".into(),
password: "originalpass123".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
};
let stolen = {
let conn = db.write().unwrap();
users::create_session(&conn, user.id, None).unwrap()
};
{
let conn = db.write().unwrap();
assert!(
users::validate_session(&conn, &stolen.token).is_ok(),
"session should be valid before the password change"
);
}
let app = crate::api::test_helpers::app_as_user(db.clone(), &user);
let body = serde_json::json!({
"current_password": "originalpass123",
"new_password": "newpassword123"
});
let resp = json_post(&app, "/api/auth/me/password", body).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
let fresh = data["token"].as_str().unwrap();
let conn = db.write().unwrap();
assert!(
users::validate_session(&conn, &stolen.token).is_err(),
"stolen session must be invalid after a password change"
);
assert!(
users::validate_session(&conn, fresh).is_ok(),
"the freshly-minted session must be usable"
);
}
#[tokio::test]
async fn revoke_all_sessions_ok() {
use tower::ServiceExt;
let app = test_app();
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri("/api/auth/me/sessions")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["revoked"], true);
}
fn login_app_with_limiter(max: usize, peer: std::net::SocketAddr) -> axum::Router {
let db = crate::db::open_memory().expect("test db");
let limiter = std::sync::Arc::new(crate::ratelimit::RateLimiter::new(
max,
std::time::Duration::from_secs(15 * 60),
));
with_client_ip_test_layers(crate::api::router(db, &[]), peer)
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::Extension(limiter))
}
fn signup_app_with_limiter(
max: usize,
peer: std::net::SocketAddr,
) -> (axum::Router, std::sync::Arc<crate::ratelimit::RateLimiter>) {
let db = crate::db::open_memory().expect("test db");
let limiter = std::sync::Arc::new(crate::ratelimit::RateLimiter::new(
max,
std::time::Duration::from_secs(15 * 60),
));
let app = with_client_ip_test_layers(crate::api::router(db, &[]), peer)
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::Extension(limiter.clone()));
(app, limiter)
}
async fn login_attempt(
app: &axum::Router,
identity: &str,
xff: &str,
) -> (StatusCode, serde_json::Value) {
use tower::ServiceExt;
let body = serde_json::json!({ "identity": identity, "password": "definitely-wrong-pw" });
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.header("x-forwarded-for", xff)
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
(status, parse_json(resp).await)
}
fn is_rate_limited(body: &serde_json::Value) -> bool {
body["error"]
.as_str()
.unwrap_or("")
.contains("too many login attempts")
}
#[tokio::test]
async fn a_successful_login_refunds_its_reservation() {
let db = crate::db::open_memory().expect("test db");
{
let conn = db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "blake".into(),
email: "blake@test.local".into(),
password: "correct horse battery".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
}
let limiter = std::sync::Arc::new(crate::ratelimit::RateLimiter::new(
3,
std::time::Duration::from_secs(15 * 60),
));
let app = with_client_ip_test_layers(crate::api::router(db, &[]), test_peer())
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::Extension(limiter.clone()));
for attempt in 0..6 {
let resp = json_post(
&app,
"/api/auth/login",
serde_json::json!({
"identity": "blake",
"password": "correct horse battery",
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK, "attempt {attempt}");
}
assert!(
!limiter.contains_key("login_id:blake"),
"a clean run of successful logins leaves no failure budget spent"
);
let (status, body) = login_attempt(&app, "blake", "10.0.0.1").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!is_rate_limited(&body));
assert!(limiter.contains_key("login_id:blake"));
}
#[tokio::test]
async fn reservations_bound_how_many_verifies_can_be_admitted() {
let limiter = std::sync::Arc::new(crate::ratelimit::RateLimiter::new(
3,
std::time::Duration::from_secs(15 * 60),
));
let held: [_; 3] = std::array::from_fn(|i| {
crate::ratelimit::Reservation::acquire(
&limiter,
&format!("login_ip:10.0.0.{i}"),
"login_id:victim",
)
.expect("within budget")
});
let app = login_app_with_limiter_arc(limiter.clone(), test_peer());
let (status, body) = login_attempt(&app, "victim", "10.0.0.99").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
is_rate_limited(&body),
"the fourth concurrent attempt must be refused, not admitted: {body}"
);
assert!(
body["error"].as_str().unwrap().contains("try again in"),
"the refusal keeps its retry guidance: {body}"
);
held.into_iter().next().unwrap().refund();
let (_, body) = login_attempt(&app, "victim", "10.0.0.98").await;
assert!(!is_rate_limited(&body), "one slot came back: {body}");
}
fn login_app_with_limiter_arc(
limiter: std::sync::Arc<crate::ratelimit::RateLimiter>,
peer: std::net::SocketAddr,
) -> axum::Router {
let db = crate::db::open_memory().expect("test db");
with_client_ip_test_layers(crate::api::router(db, &[]), peer)
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::Extension(limiter))
}
#[tokio::test]
async fn login_grants_full_per_identity_budget() {
let app = login_app_with_limiter(5, test_peer());
for i in 0..5 {
let (status, body) = login_attempt(&app, "victim", &format!("10.0.0.{i}")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
!is_rate_limited(&body),
"attempt {i} should be an auth failure, not rate-limited: {body}"
);
}
let (status, body) = login_attempt(&app, "victim", "10.0.0.250").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
is_rate_limited(&body),
"6th attempt should be rate-limited by the identity bucket: {body}"
);
}
#[tokio::test]
async fn login_rate_limit_applies_per_ip_across_identities() {
let app = login_app_with_limiter(5, test_peer());
for i in 0..5 {
let (status, body) = login_attempt(&app, &format!("user{i}"), "203.0.113.5").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
!is_rate_limited(&body),
"attempt {i} should be an auth failure: {body}"
);
}
let (status, body) = login_attempt(&app, "user-brand-new", "203.0.113.5").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
is_rate_limited(&body),
"6th attempt from the same IP should be rate-limited: {body}"
);
}
#[tokio::test]
async fn login_rate_limit_ignores_spoofed_xff_from_untrusted_peer() {
let peer = std::net::SocketAddr::from(([203, 0, 113, 5], 4242));
let app = login_app_with_limiter(2, peer);
for (i, spoofed_xff) in ["198.51.100.1", "198.51.100.2"].iter().enumerate() {
let (status, body) = login_attempt(&app, &format!("user{i}"), spoofed_xff).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
!is_rate_limited(&body),
"attempt {i} should consume, but not exceed, the peer-IP budget: {body}"
);
}
let (status, body) = login_attempt(&app, "third-user", "198.51.100.3").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
is_rate_limited(&body),
"rotating spoofed XFF must not evade the untrusted peer's bucket: {body}"
);
}
#[tokio::test]
async fn login_rate_limit_isolates_distinct_ips() {
let app = login_app_with_limiter(3, test_peer());
for i in 0..3 {
let (_, body) = login_attempt(&app, &format!("a{i}"), "198.51.100.1").await;
assert!(!is_rate_limited(&body), "setup attempt {i}: {body}");
}
let (_, attacker) = login_attempt(&app, "a-extra", "198.51.100.1").await;
assert!(
is_rate_limited(&attacker),
"attacker IP should be capped: {attacker}"
);
let (status, other) = signup_attempt(&app, 60, "198.51.100.8").await;
assert!(
status == StatusCode::OK,
"distinct IP should not be limited: {other}"
);
}
async fn signup_attempt(
app: &axum::Router,
n: usize,
xff: &str,
) -> (StatusCode, serde_json::Value) {
use tower::ServiceExt;
let body = serde_json::json!({
"username": format!("user{n}"),
"email": format!("user{n}@test.com"),
"password": "securepass123",
});
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/auth/signup")
.header("content-type", "application/json")
.header("x-forwarded-for", xff)
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
(status, parse_json(resp).await)
}
fn is_signup_rate_limited(body: &serde_json::Value) -> bool {
body["error"]
.as_str()
.unwrap_or("")
.contains("too many signup attempts")
}
#[tokio::test]
async fn signup_rate_limit_applies_per_ip_across_emails() {
let app = login_app_with_limiter(5, test_peer());
for i in 0..5 {
let (status, body) = signup_attempt(&app, i, "203.0.113.9").await;
assert_eq!(status, StatusCode::OK, "signup {i} should succeed: {body}");
assert!(
!is_signup_rate_limited(&body),
"signup {i} not yet limited: {body}"
);
}
let (status, body) = signup_attempt(&app, 99, "203.0.113.9").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
is_signup_rate_limited(&body),
"6th signup from the same IP should be rate-limited: {body}"
);
}
#[tokio::test]
async fn signup_rate_limit_isolates_distinct_ips() {
let app = login_app_with_limiter(3, test_peer());
for i in 0..3 {
let (status, _) = signup_attempt(&app, i, "198.51.100.7").await;
assert_eq!(status, StatusCode::OK);
}
let (_, capped) = signup_attempt(&app, 50, "198.51.100.7").await;
assert!(
is_signup_rate_limited(&capped),
"capped IP should be blocked: {capped}"
);
let (status, other) = signup_attempt(&app, 60, "198.51.100.8").await;
assert_eq!(
status,
StatusCode::OK,
"distinct IP should not be limited: {other}"
);
}
#[tokio::test]
async fn blocked_signup_does_not_allocate_a_new_email_key() {
let (app, limiter) = signup_app_with_limiter(1, test_peer());
let (status, _) = signup_attempt(&app, 0, "203.0.113.10").await;
assert_eq!(status, StatusCode::OK);
let (status, body) = signup_attempt(&app, 1, "203.0.113.10").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(is_signup_rate_limited(&body));
assert!(!limiter.contains_key("signup:user1@test.com"));
}
use crate::db::DbPool;
use crate::db::models::{CreateUser, User};
use serde_json::json;
fn roster_db() -> (DbPool, User, User, User, User) {
let db = crate::db::open_memory().expect("test db");
let conn = db.write().unwrap();
let mk = |username: &str, is_admin: bool| {
crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: username.into(),
email: format!("{username}@test.com"),
password: "testpassword1".into(),
display_name: None,
is_admin,
is_bot: false,
},
)
.unwrap()
};
let admin = mk("admin", true);
let other_admin = mk("second-admin", true);
let member = mk("member", false);
let bot =
crate::db::queries::users::ensure_bot(&conn, admin.id, "opencode", "OpenCode").unwrap();
drop(conn);
(db, admin, other_admin, member, bot)
}
fn fresh_session(db: &DbPool, user: &User) -> String {
let conn = db.write().unwrap();
crate::db::queries::users::create_session(&conn, user.id, None)
.unwrap()
.token
}
async fn json_post_as(
app: &axum::Router,
uri: &str,
body: serde_json::Value,
token: &str,
) -> axum::response::Response {
use tower::ServiceExt;
app.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
fn reload(db: &DbPool, id: i64) -> User {
let conn = db.read().unwrap();
crate::db::queries::users::get_user_by_id(&conn, id).unwrap()
}
#[tokio::test]
async fn a_stale_admin_snapshot_cannot_expand_access() {
let (db, admin, other, member, _bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let session = fresh_session(&db, &admin);
{
let conn = db.write().unwrap();
crate::db::queries::users::set_admin_guarded(&conn, admin.id, false).unwrap();
}
assert!(!reload(&db, admin.id).is_admin);
let create = json_post_as(
&app,
"/api/users",
serde_json::json!({ "username": "ghost", "password": "securepass123" }),
&session,
)
.await;
assert_eq!(create.status(), StatusCode::FORBIDDEN, "create");
let promote = json_post_as(
&app,
&format!("/api/users/{}/promote", member.id),
json!({}),
&session,
)
.await;
assert_eq!(promote.status(), StatusCode::FORBIDDEN, "promote");
{
let conn = db.write().unwrap();
crate::db::queries::users::set_active(&conn, member.id, false).unwrap();
}
let reactivate = json_post_as(
&app,
&format!("/api/users/{}/reactivate", member.id),
json!({}),
&session,
)
.await;
assert_eq!(reactivate.status(), StatusCode::FORBIDDEN, "reactivate");
let settings = {
use tower::ServiceExt;
app.clone()
.oneshot(
axum::http::Request::builder()
.method("PATCH")
.uri("/api/instance/settings")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {session}"))
.body(axum::body::Body::from(
serde_json::to_vec(&serde_json::json!({"allow_signup": true})).unwrap(),
))
.unwrap(),
)
.await
.unwrap()
};
assert_eq!(
settings.status(),
StatusCode::FORBIDDEN,
"instance settings"
);
assert!(
crate::db::queries::users::get_user_by_username(&db.read().unwrap(), "ghost").is_err()
);
assert!(!reload(&db, member.id).is_admin);
assert!(!reload(&db, member.id).is_active);
let _ = other;
}
#[tokio::test]
async fn a_stale_admin_snapshot_cannot_reach_another_accounts_credentials() {
let (db, admin, other, member, _bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let manager = crate::auth::create_key_manager().unwrap();
let victim_bot = {
let conn = db.write().unwrap();
crate::db::queries::users::ensure_bot(&conn, member.id, "zed", "Zed").unwrap()
};
crate::auth::create_api_key(&db, &manager, "victim-key", Some(member.id)).unwrap();
crate::auth::create_api_key(&db, &manager, "victim-bot-key", Some(victim_bot.id)).unwrap();
let victim_key_id: i64 = db
.read()
.unwrap()
.query_row(
"SELECT id FROM api_keys WHERE name = 'victim-key'",
[],
|r| r.get(0),
)
.unwrap();
{
let conn = db.write().unwrap();
crate::db::queries::users::set_admin_guarded(&conn, admin.id, false).unwrap();
}
let revoke = json_delete(&app, &format!("/api/auth/keys/{victim_key_id}")).await;
assert_ne!(revoke.status(), StatusCode::OK, "revoke another's key");
let disconnect = json_post(
&app,
&format!("/api/auth/bots/{}/disconnect", victim_bot.id),
json!({}),
)
.await;
assert_ne!(
disconnect.status(),
StatusCode::OK,
"disconnect another's tool"
);
let delete = json_delete(&app, &format!("/api/auth/bots/{}", victim_bot.id)).await;
assert_ne!(delete.status(), StatusCode::OK, "delete another's tool");
let demote = json_post(&app, &format!("/api/users/{}/demote", other.id), json!({})).await;
assert_eq!(demote.status(), StatusCode::FORBIDDEN, "demote");
let deactivate = json_post(
&app,
&format!("/api/users/{}/deactivate", other.id),
json!({}),
)
.await;
assert_eq!(deactivate.status(), StatusCode::FORBIDDEN, "deactivate");
let conn = db.read().unwrap();
let live: i64 = conn
.query_row(
"SELECT COUNT(*) FROM api_keys WHERE revoked = 0 AND name LIKE 'victim%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(live, 2, "the victim's credentials are untouched");
drop(conn);
assert!(reload(&db, other.id).is_admin);
assert!(reload(&db, other.id).is_active);
}
#[tokio::test]
async fn deactivating_an_account_broadcasts_a_revocation_for_it_and_its_bots() {
let (db, admin, _other, member, _bot) = roster_db();
let victim_bot = {
let conn = db.write().unwrap();
crate::db::queries::users::ensure_bot(&conn, member.id, "zed", "Zed").unwrap()
};
let realtime = crate::realtime::RealtimeHub::new();
let mut revocations = realtime.subscribe_revocations();
let app = app_as_user_with_realtime(db.clone(), &admin, realtime);
let resp = json_post(
&app,
&format!("/api/users/{}/deactivate", member.id),
json!({}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let mut told = Vec::new();
while let Ok(id) = revocations.try_recv() {
told.push(id);
}
told.sort_unstable();
let mut expected = vec![member.id, victim_bot.id];
expected.sort_unstable();
assert_eq!(
told, expected,
"the account and every bot it owns are told at once"
);
}
#[tokio::test]
async fn admin_can_create_a_user_from_the_roster() {
let (db, admin, _other, _member, _bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let session = fresh_session(&db, &admin);
let resp = json_post_as(
&app,
"/api/users",
serde_json::json!({ "username": "newcomer", "password": "securepass123" }),
&session,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let data = parse_json(resp).await;
assert_eq!(data["username"], "newcomer");
assert_eq!(data["is_admin"], false);
assert_eq!(data["is_active"], true);
let created = reload(&db, data["id"].as_i64().unwrap());
assert_eq!(created.email, "newcomer@local");
assert!(!created.is_bot, "the roster never mints a bot");
}
#[tokio::test]
async fn admin_can_promote_and_demote_a_member() {
let (db, admin, _other, member, _bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let session = fresh_session(&db, &admin);
let resp = json_post_as(
&app,
&format!("/api/users/{}/promote", member.id),
json!({}),
&session,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["is_admin"], true);
assert!(reload(&db, member.id).is_admin);
let resp = json_post(&app, &format!("/api/users/{}/demote", member.id), json!({})).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["is_admin"], false);
assert!(!reload(&db, member.id).is_admin);
}
#[tokio::test]
async fn admin_can_deactivate_and_restore_a_member() {
let (db, admin, _other, member, _bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let resp = json_post(
&app,
&format!("/api/users/{}/deactivate", member.id),
json!({}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["is_active"], false);
assert!(!reload(&db, member.id).is_active);
let listed = parse_json(json_get(&app, "/api/users").await).await;
assert!(
listed
.as_array()
.unwrap()
.iter()
.any(|u| u["id"] == member.id && u["is_active"] == false),
"a deactivated member stays visible: {listed}"
);
let resp = json_post(
&app,
&format!("/api/users/{}/reactivate", member.id),
json!({}),
)
.await;
let status = resp.status();
let body = parse_json(resp).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["is_active"], true);
assert!(reload(&db, member.id).is_active);
}
#[tokio::test]
async fn deactivation_kills_the_accounts_sessions() {
let (db, admin, _other, member, _bot) = roster_db();
let token = {
let conn = db.write().unwrap();
crate::db::queries::users::create_session(&conn, member.id, None)
.unwrap()
.token
};
let app = app_as_user(db.clone(), &admin);
let resp = json_post(
&app,
&format!("/api/users/{}/deactivate", member.id),
json!({}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let conn = db.read().unwrap();
assert!(
crate::db::queries::users::validate_session(&conn, &token).is_err(),
"the deactivated user's session must no longer authenticate"
);
}
#[tokio::test]
async fn a_non_admin_cannot_reach_any_roster_mutation() {
let (db, admin, _other, member, _bot) = roster_db();
let app = app_as_user(db.clone(), &member);
let resp = json_post(
&app,
"/api/users",
serde_json::json!({ "username": "smuggled", "password": "securepass123" }),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "create is admin-only");
for action in ["promote", "demote", "deactivate", "reactivate"] {
let resp = json_post(
&app,
&format!("/api/users/{}/{action}", admin.id),
json!({}),
)
.await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"{action} must be admin-only"
);
}
let still = reload(&db, admin.id);
assert!(still.is_admin && still.is_active);
assert!(
crate::db::queries::users::get_user_by_username(&db.read().unwrap(), "smuggled")
.is_err(),
"the rejected create wrote nothing"
);
}
#[tokio::test]
async fn the_last_admin_cannot_be_demoted_or_deactivated() {
let (db, admin, other, _member, _bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let resp = json_post(&app, &format!("/api/users/{}/demote", other.id), json!({})).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
{
let conn = db.read().unwrap();
crate::db::queries::users::count_active_admins(&conn).unwrap()
},
1
);
for action in ["demote", "deactivate"] {
let resp = json_post(
&app,
&format!("/api/users/{}/{action}", admin.id),
json!({}),
)
.await;
assert_eq!(
resp.status(),
StatusCode::CONFLICT,
"{action} of the last admin must be refused"
);
let data = parse_json(resp).await;
assert!(
data["error"]
.as_str()
.unwrap()
.contains("last instance admin"),
"{action}: {data}"
);
}
let still = reload(&db, admin.id);
assert!(
still.is_admin && still.is_active,
"the last admin survived both attempts"
);
}
#[tokio::test]
async fn bot_identities_are_not_roster_targets() {
let (db, admin, _other, _member, bot) = roster_db();
let app = app_as_user(db.clone(), &admin);
let session = fresh_session(&db, &admin);
for action in ["promote", "demote", "deactivate", "reactivate"] {
let resp = json_post_as(
&app,
&format!("/api/users/{}/{action}", bot.id),
json!({}),
&session,
)
.await;
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"{action} must refuse a bot target"
);
let data = parse_json(resp).await;
assert!(
data["error"].as_str().unwrap().contains("Connected Tools"),
"{action}: {data}"
);
}
let still = reload(&db, bot.id);
assert!(!still.is_admin && still.is_active, "the bot is unchanged");
}
}