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, 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(|exp| {
let exp_utc: DateTime<chrono::Utc> = exp.into();
(exp_utc - chrono::Utc::now()).num_seconds().max(0)
})
.unwrap_or(30 * 24 * 3600);
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>,
}
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
&& (!rl.check(&email_key) || !rl.check(&ip_key))
{
let retry = rl.retry_after(&email_key).max(rl.retry_after(&ip_key));
return Err(LificError::BadRequest(format!(
"too many signup attempts — try again in {retry} seconds"
)));
}
let conn = db.write()?;
let settings = crate::db::queries::settings::get(&conn)?;
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 = input
.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(", ")
)));
}
}
let user = crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: input.username,
email: input.email,
password: input.password,
display_name: input.display_name,
is_admin: false,
is_bot: false,
},
)?;
let session = crate::db::queries::users::create_session(
&conn,
user.id,
Some(settings.session_lifetime_days * 24),
)?;
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_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)
);
if let Some(Extension(ref rl)) = limiter
&& (!rl.peek(&id_key) || !rl.peek(&ip_key))
{
let retry = rl.retry_after(&id_key).max(rl.retry_after(&ip_key));
return Err(LificError::BadRequest(format!(
"too many login attempts — try again in {retry} seconds"
)));
}
let conn = db.write()?;
let user =
match crate::db::queries::users::authenticate(&conn, &input.identity, &input.password) {
Ok(u) => u,
Err(e) => {
if let Some(Extension(ref rl)) = limiter {
rl.record_failure(&id_key);
rl.record_failure(&ip_key);
}
return Err(e);
}
};
let lifetime_days = crate::db::queries::settings::get(&conn)
.map(|s| s.session_lifetime_days)
.unwrap_or(30);
let session =
crate::db::queries::users::create_session(&conn, user.id, Some(lifetime_days * 24))?;
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::db::queries::users::first_admin(&conn)?
.ok_or_else(|| LificError::BadRequest("no admin account exists to sign in as".into()))?;
let session = crate::db::queries::users::create_session(
&conn,
admin.id,
Some(settings.session_lifetime_days * 24),
)?;
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(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<serde_json::Value>, LificError> {
require_admin(&auth_user)?;
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(auth_user): Extension<Option<AuthUser>>,
Json(input): Json<InstanceSettingsPatchReq>,
) -> Result<Json<serde_json::Value>, LificError> {
require_admin(&auth_user)?;
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) = with_write(&db, move |conn| {
let previous_authz_enforced = crate::db::queries::settings::get(conn)?.authz_enforced;
let settings = crate::db::queries::settings::update(conn, 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(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user
.ok_or_else(|| LificError::BadRequest("no user associated with this token".into()))?;
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(auth_user): Extension<Option<AuthUser>>,
Json(input): Json<UpdateMeRequest>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
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,
}
pub(super) async fn change_password(
State(db): State<DbPool>,
Extension(auth_cfg): Extension<crate::config::AuthConfig>,
Extension(auth_user): Extension<Option<AuthUser>>,
Json(input): Json<ChangePasswordRequest>,
) -> Result<impl IntoResponse, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let session = with_write(&db, |conn| {
let full = crate::db::queries::users::get_user_by_id(conn, user.id)?;
let ok = crate::db::queries::users::verify_password(
&input.current_password,
&full.password_hash,
)?;
if !ok {
return Err(LificError::BadRequest(
"current password is incorrect".into(),
));
}
crate::db::queries::users::update_password(conn, user.id, &input.new_password)?;
crate::db::queries::users::delete_all_sessions(conn, user.id)?;
crate::db::queries::users::create_session(conn, user.id, None)
})?;
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(auth_user): Extension<Option<AuthUser>>,
) -> Result<impl IntoResponse, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
with_write(&db, |conn| {
crate::db::queries::users::delete_all_sessions(conn, 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(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<Vec<UserApiKey>>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
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(auth_user): Extension<Option<AuthUser>>,
Extension(manager): Extension<std::sync::Arc<api_keys_simplified::ApiKeyManagerV0>>,
Json(input): Json<CreateKeyRequest>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let name = input.name.trim().to_string();
if name.is_empty() {
return Err(LificError::BadRequest("key name cannot be empty".into()));
}
let plaintext = crate::auth::create_api_key(&db, &manager, &name)?;
let conn = db.write()?;
crate::db::queries::users::assign_key_to_user(&conn, &name, 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(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let conn = db.write()?;
crate::db::queries::users::revoke_user_key(&conn, id, user.id, user.is_admin)?;
Ok(Json(serde_json::json!({"revoked": true})))
}
pub(super) async fn list_bots(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<Vec<Bot>>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
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(auth_user): Extension<Option<AuthUser>>,
Extension(manager): Extension<std::sync::Arc<api_keys_simplified::ApiKeyManagerV0>>,
Json(input): Json<CreateBotRequest>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
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 existing_bot = with_read(&db, |conn| {
crate::db::queries::users::find_bot_by_username(conn, &bot_username)
})
.ok()
.flatten();
let bot_user = if let Some(existing) = existing_bot {
let has_key = with_read(&db, |conn| {
crate::db::queries::users::bot_has_active_key(conn, existing.id)
})?;
if has_key {
return Err(LificError::BadRequest(format!(
"{display_name} is already connected"
)));
}
existing
} else {
with_write(&db, |conn| {
crate::db::queries::users::create_bot_user(conn, user.id, &bot_username, display_name)
})?
};
let plaintext_key = crate::auth::create_api_key(&db, &manager, &bot_username)?;
let conn = db.write()?;
crate::db::queries::users::assign_key_to_user(&conn, &bot_username, bot_user.id)?;
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(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let conn = db.write()?;
crate::db::queries::users::disconnect_bot(&conn, id, user.id, user.is_admin)?;
Ok(Json(serde_json::json!({"disconnected": true})))
}
pub(super) async fn delete_bot(
State(db): State<DbPool>,
Path(id): Path<i64>,
Extension(auth_user): Extension<Option<AuthUser>>,
) -> Result<Json<serde_json::Value>, LificError> {
let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?;
let conn = db.write()?;
crate::db::queries::users::delete_bot(&conn, id, user.id, user.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,
created_at: String,
}
pub(super) async fn list_users(
State(db): State<DbPool>,
) -> Result<Json<Vec<UserListItem>>, LificError> {
with_read(&db, |conn| {
let users = crate::db::queries::users::list_users(conn)?;
Ok(users
.into_iter()
.filter(|u| !u.is_bot)
.map(|u| UserListItem {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
created_at: u.created_at,
})
.collect())
})
.map(Json)
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use axum::http::StatusCode;
#[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());
}
#[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");
}
#[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);
}
#[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 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))
}
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 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 (_, other) = login_attempt(&app, "someone", "198.51.100.2").await;
assert!(
!is_rate_limited(&other),
"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}"
);
}
}