use axum::{
body::Body,
extract::State,
http::{HeaderMap, Method, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use rusqlite::params;
use tracing::{info, warn};
use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret, KeyStatus};
use crate::db::DbPool;
use crate::db::models::AuthUser;
#[derive(Clone)]
pub struct AuthState {
pub db: DbPool,
pub manager: ApiKeyManagerV0,
pub public_url: String,
pub required: bool,
}
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail");
}
encoded
}
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex_encode(&Sha256::digest(bytes))
}
pub fn create_key_manager() -> Result<ApiKeyManagerV0, String> {
ApiKeyManagerV0::init_default_config("lific_sk")
.map_err(|e| format!("failed to init key manager: {e}"))
}
pub fn create_api_key(
db: &DbPool,
manager: &ApiKeyManagerV0,
name: &str,
user_id: Option<i64>,
) -> Result<String, crate::error::LificError> {
create_api_key_with_expiry(db, manager, name, None, user_id)
}
pub fn create_api_key_with_expiry(
db: &DbPool,
manager: &ApiKeyManagerV0,
name: &str,
expires_at: Option<&str>,
user_id: Option<i64>,
) -> Result<String, crate::error::LificError> {
let prepared = PreparedApiKey::generate(manager)?;
db.transaction(|tx| prepared.insert(tx, name, expires_at, user_id))
}
pub struct PreparedApiKey {
plaintext: String,
hash: String,
key_id: String,
}
impl PreparedApiKey {
pub fn generate(manager: &ApiKeyManagerV0) -> Result<Self, crate::error::LificError> {
let api_key = manager.generate(Environment::production()).map_err(|e| {
crate::error::LificError::Internal(format!("key generation failed: {e}"))
})?;
Ok(Self {
plaintext: api_key.key().expose_secret().to_string(),
hash: api_key.expose_hash().hash().to_string(),
key_id: api_key.expose_hash().key_id().to_string(),
})
}
pub fn insert(
self,
conn: &rusqlite::Connection,
name: &str,
expires_at: Option<&str>,
user_id: Option<i64>,
) -> Result<String, crate::error::LificError> {
let mut stmt = conn.prepare("SELECT user_id, revoked FROM api_keys WHERE name = ?1")?;
let existing = stmt
.query_map(params![name], |row| {
Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, bool>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
drop(stmt);
if existing.iter().any(|(_, revoked)| !revoked) {
return Err(crate::error::LificError::BadRequest(format!(
"an active key named '{name}' already exists"
)));
}
if existing.iter().any(|(owner, _)| *owner != user_id) {
return Err(crate::error::LificError::BadRequest(format!(
"the key name '{name}' is already reserved by another owner"
)));
}
if !existing.is_empty() {
conn.execute(
"DELETE FROM api_keys WHERE name = ?1 AND revoked = 1",
params![name],
)?;
}
conn.execute(
"INSERT INTO api_keys (name, key_hash, key_id, expires_at, user_id) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![name, self.hash, self.key_id, expires_at, user_id],
)?;
Ok(self.plaintext)
}
}
fn recent_auth_required() -> crate::error::LificError {
crate::error::LificError::Forbidden("recent authentication required".into())
}
pub fn recent_session_token(headers: &HeaderMap) -> Result<String, crate::error::LificError> {
session_bearer_token(headers).map_err(|_| recent_auth_required())
}
pub fn session_bearer_token(headers: &HeaderMap) -> Result<String, crate::error::LificError> {
let token = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.ok_or_else(|| {
crate::error::LificError::Forbidden(
"this action requires a signed-in browser session".into(),
)
})?;
if !token.starts_with("lific_sess_") {
return Err(crate::error::LificError::Forbidden(
"this action requires a signed-in browser session".into(),
));
}
Ok(token.to_string())
}
pub fn revalidate_recent_session(
conn: &rusqlite::Connection,
token: &str,
expected_user_id: i64,
) -> Result<crate::db::models::User, crate::error::LificError> {
let user = crate::db::queries::users::validate_session(conn, token)
.map_err(|_| recent_auth_required())?;
if user.id != expected_user_id {
return Err(recent_auth_required());
}
if !crate::db::queries::users::session_is_recent(conn, token)? {
return Err(recent_auth_required());
}
Ok(user)
}
pub fn fresh_auth_user(user: &crate::db::models::User) -> AuthUser {
AuthUser {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
is_admin: user.is_admin,
}
}
pub fn fresh_identity(
user: &crate::db::models::User,
transport: crate::actor::Transport,
) -> crate::resolve_caller::ResolvedIdentity {
crate::resolve_caller::ResolvedIdentity {
user: fresh_auth_user(user),
transport,
}
}
pub fn fresh_caller(
conn: &rusqlite::Connection,
caller_id: i64,
) -> Result<crate::db::models::User, crate::error::LificError> {
let user = crate::db::queries::users::get_user_by_id(conn, caller_id)
.map_err(|_| crate::error::LificError::Forbidden("authentication required".into()))?;
if !crate::db::queries::users::credential_is_live(conn, &user)? {
return Err(crate::error::LificError::Forbidden(
"authentication required".into(),
));
}
Ok(user)
}
pub fn require_fresh_admin(user: &crate::db::models::User) -> Result<(), crate::error::LificError> {
if user.is_admin {
Ok(())
} else {
Err(crate::error::LificError::Forbidden(
"only an admin can do this".into(),
))
}
}
pub fn list_api_keys(db: &DbPool) -> Result<Vec<ApiKeyInfo>, crate::error::LificError> {
let conn = db.read()?;
let mut stmt = conn.prepare(
"SELECT id, name, created_at, expires_at, revoked FROM api_keys ORDER BY created_at",
)?;
let rows = stmt.query_map([], |row| {
Ok(ApiKeyInfo {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
expires_at: row.get(3)?,
revoked: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(crate::error::LificError::Database)
}
pub fn revoke_api_key(db: &DbPool, name: &str) -> Result<(), crate::error::LificError> {
let conn = db.write()?;
let changed = conn.execute(
"UPDATE api_keys SET revoked = 1 WHERE name = ?1 AND revoked = 0",
params![name],
)?;
drop(conn);
if changed == 0 {
return Err(crate::error::LificError::NotFound(format!(
"no active key named '{name}'"
)));
}
info!(name, "API key revoked");
Ok(())
}
pub fn rotate_api_key(
db: &DbPool,
manager: &ApiKeyManagerV0,
name: &str,
) -> Result<String, crate::error::LificError> {
rotate_api_key_bound(db, manager, name, None)
}
pub fn rotate_api_key_bound(
db: &DbPool,
manager: &ApiKeyManagerV0,
name: &str,
user_id: Option<i64>,
) -> Result<String, crate::error::LificError> {
let prepared = PreparedApiKey::generate(manager)?;
db.transaction(|tx| {
let previous_user_id: Option<i64> = tx
.query_row(
"SELECT user_id FROM api_keys WHERE name = ?1 ORDER BY revoked ASC, id DESC LIMIT 1",
params![name],
|row| row.get(0),
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
crate::error::LificError::NotFound(format!("no key named '{name}'"))
}
other => other.into(),
})?;
tx.execute("DELETE FROM api_keys WHERE name = ?1", params![name])?;
prepared.insert(tx, name, None, user_id.or(previous_user_id))
})
}
pub fn has_any_keys(db: &DbPool) -> bool {
if let Ok(conn) = db.read() {
conn.query_row("SELECT COUNT(*) FROM api_keys", [], |row| {
row.get::<_, i64>(0)
})
.unwrap_or(0)
> 0
} else {
false
}
}
pub fn has_human_operator(db: &DbPool) -> bool {
if let Ok(conn) = db.read() {
crate::db::queries::users::has_human_users(&conn).unwrap_or(false)
} else {
false
}
}
pub fn should_mint_initial_key(db: &DbPool) -> bool {
!has_any_keys(db) && !has_human_operator(db)
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct ApiKeyInfo {
pub id: i64,
pub name: String,
pub created_at: String,
pub expires_at: Option<String>,
pub revoked: bool,
}
fn session_cookie_token(headers: &HeaderMap) -> Option<String> {
let cookies = headers.get("cookie").and_then(|v| v.to_str().ok())?;
let value = cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("lific_token=")
.map(|v| v.trim().to_string())
})?;
value.starts_with("lific_sess_").then_some(value)
}
fn is_attachment_download(method: &Method, path: &str) -> bool {
if method != Method::GET {
return false;
}
let Some(rest) = path.strip_prefix("/api/attachments/") else {
return false;
};
let rest = rest.strip_suffix('/').unwrap_or(rest);
let id = rest.strip_suffix("/thumbnail").unwrap_or(rest);
!id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialKind {
Session,
ApiKey,
Oauth,
Anonymous,
}
fn oauth_blocked_reason(method: &Method, path: &str) -> Option<&'static str> {
let path = path.strip_suffix('/').unwrap_or(path);
if path == "/api/auth/keys" || path.starts_with("/api/auth/keys/") {
return Some("API key management is not available to OAuth-token callers");
}
if path == "/api/auth/bots" || path.starts_with("/api/auth/bots/") {
return Some("connected-tool management is not available to OAuth-token callers");
}
if path == "/api/auth/me/password"
|| path == "/api/auth/me/sessions"
|| path == "/api/auth/me/refresh"
{
return Some("account credential changes are not available to OAuth-token callers");
}
if path == "/api/auth/me" && method != Method::GET {
return Some("account changes are not available to OAuth-token callers");
}
if (path == "/api/users" && method != Method::GET) || path.starts_with("/api/users/") {
return Some("user administration is not available to OAuth-token callers");
}
if path == "/oauth" || path.starts_with("/oauth/") {
return Some(
"OAuth client and token administration is not available to OAuth-token callers",
);
}
None
}
fn insert_identity_extensions(
request: &mut Request<Body>,
db: &DbPool,
credential_user: Option<crate::db::models::AuthUser>,
is_mcp_request: bool,
default: crate::actor::Transport,
kind: CredentialKind,
) {
let transport = if is_mcp_request {
crate::actor::Transport::Mcp
} else {
default
};
let resolved = match crate::resolve_caller::resolve_caller(db, credential_user, transport) {
Ok(id) => id,
Err(e) => {
warn!(error = %e, "resolved-identity lookup failed; degrading to None");
None
}
};
request.extensions_mut().insert(resolved);
request.extensions_mut().insert(kind);
}
pub async fn require_api_key(
State(auth): State<AuthState>,
mut request: Request<Body>,
next: Next,
) -> Response {
let token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let is_mcp_request = request.uri().path() == "/mcp";
if is_mcp_request {
let token_kind = match token.as_deref() {
Some(t) if t.starts_with("lific_sess_") => "session",
Some(t) if t.starts_with("lific_at_") => "oauth",
Some(t) if t.starts_with("lific_sk") => "api_key",
Some(_) => "unknown",
None => "none",
};
info!(method = %request.method(), token_kind, "/mcp request received");
}
let www_auth = format!(
"Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource/mcp\"",
auth.public_url
);
let Some(token) = token else {
if is_attachment_download(request.method(), request.uri().path())
&& let Some(cookie_token) = session_cookie_token(request.headers())
{
let user = {
let conn = match auth.db.read() {
Ok(c) => c,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "database error")
.into_response();
}
};
crate::db::queries::users::validate_session(&conn, &cookie_token)
};
if let Ok(u) = user {
let auth_user = crate::db::models::AuthUser {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
};
let actor = crate::actor::ActorCtx {
user_id: Some(auth_user.id),
transport: crate::actor::Transport::Web,
};
insert_identity_extensions(
&mut request,
&auth.db,
Some(auth_user.clone()),
false,
crate::actor::Transport::Web,
CredentialKind::Session,
);
request.extensions_mut().insert(Some(auth_user));
return crate::actor::scope(actor, next.run(request)).await;
}
}
if !auth.required {
let default = if is_mcp_request {
crate::actor::Transport::Mcp
} else {
crate::actor::Transport::Api
};
let actor = crate::actor::ActorCtx {
user_id: None,
transport: default,
};
insert_identity_extensions(
&mut request,
&auth.db,
None,
is_mcp_request,
default,
CredentialKind::Anonymous,
);
request
.extensions_mut()
.insert(Option::<crate::db::models::AuthUser>::None);
return crate::actor::scope(actor, next.run(request)).await;
}
if is_mcp_request {
info!("/mcp rejected: no Authorization header (discovery probe or dropped token)");
}
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Missing Authorization: Bearer <key> header",
)
.into_response();
};
if token.starts_with("lific_sess_") {
let user = {
let conn = match auth.db.read() {
Ok(c) => c,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
};
crate::db::queries::users::validate_session(&conn, &token)
};
match user {
Ok(u) => {
let auth_user = crate::db::models::AuthUser {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
};
let actor = crate::actor::ActorCtx {
user_id: Some(auth_user.id),
transport: if is_mcp_request {
crate::actor::Transport::Mcp
} else {
crate::actor::Transport::Web
},
};
insert_identity_extensions(
&mut request,
&auth.db,
Some(auth_user.clone()),
is_mcp_request,
crate::actor::Transport::Web,
CredentialKind::Session,
);
request.extensions_mut().insert(Some(auth_user));
return crate::actor::scope(actor, next.run(request)).await;
}
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid or expired session",
)
.into_response();
}
}
}
if token.starts_with("lific_at_") {
match crate::oauth::resolve_oauth_credential(&auth.db, &token) {
Ok(credential) => {
if is_mcp_request {
info!("/mcp authorized: OAuth token accepted");
}
let auth_user = match credential {
crate::oauth::OAuthCredential::Bound(user) => Some(user),
crate::oauth::OAuthCredential::LegacyUnbound => None,
};
if let Some(reason) = oauth_blocked_reason(request.method(), request.uri().path()) {
warn!(
method = %request.method(),
path = %request.uri().path(),
"OAuth token refused on a credential-management route"
);
return (
StatusCode::FORBIDDEN,
format!(
"{reason}. Authenticate with a session or API key for this endpoint."
),
)
.into_response();
}
let actor = crate::actor::ActorCtx {
user_id: auth_user.as_ref().map(|u| u.id),
transport: if is_mcp_request {
crate::actor::Transport::Mcp
} else {
crate::actor::Transport::Api
},
};
insert_identity_extensions(
&mut request,
&auth.db,
auth_user.clone(),
is_mcp_request,
crate::actor::Transport::Api,
CredentialKind::Oauth,
);
request.extensions_mut().insert(auth_user);
return crate::actor::scope(actor, next.run(request)).await;
}
Err(crate::oauth::OAuthReject::DeadBinding) => {
if is_mcp_request {
warn!("/mcp rejected: OAuth token bound to a missing or deactivated user");
}
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"OAuth token bound to a missing or deactivated user",
)
.into_response();
}
Err(crate::oauth::OAuthReject::Unavailable) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
Err(crate::oauth::OAuthReject::Invalid) => {
if is_mcp_request {
warn!("/mcp rejected: OAuth token invalid or expired");
}
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid or expired OAuth token",
)
.into_response();
}
}
}
let auth_user = match validate_api_key(&auth.db, &auth.manager, &token) {
Ok(user) => user,
Err(ApiKeyReject::BadChecksum) => {
warn!("rejected API key with invalid checksum");
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid API key",
)
.into_response();
}
Err(ApiKeyReject::Db) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
Err(ApiKeyReject::NotFound) => {
warn!("rejected invalid API key");
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid API key",
)
.into_response();
}
Err(ApiKeyReject::HashMismatch) => {
warn!("API key hash verification failed");
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid API key",
)
.into_response();
}
Err(ApiKeyReject::Inactive) => {
warn!("rejected API key: deactivated account, or a bot with a deactivated owner");
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"This account is deactivated",
)
.into_response();
}
};
let actor = crate::actor::ActorCtx {
user_id: auth_user.as_ref().map(|u| u.id),
transport: if is_mcp_request {
crate::actor::Transport::Mcp
} else {
crate::actor::Transport::Api
},
};
insert_identity_extensions(
&mut request,
&auth.db,
auth_user.clone(),
is_mcp_request,
crate::actor::Transport::Api,
CredentialKind::ApiKey,
);
request.extensions_mut().insert(auth_user);
crate::actor::scope(actor, next.run(request)).await
}
#[derive(Debug)]
struct ApiKeyRow {
id: i64,
hash: String,
user_id: Option<i64>,
}
#[derive(Debug, Clone, Copy)]
enum ApiKeyReject {
Db,
BadChecksum,
NotFound,
HashMismatch,
Inactive,
}
fn validate_api_key(
db: &DbPool,
manager: &ApiKeyManagerV0,
token: &str,
) -> Result<Option<AuthUser>, ApiKeyReject> {
use api_keys_simplified::SecureString;
let secure_token = SecureString::from(token.to_string());
match manager.verify_checksum(&secure_token) {
Ok(true) => {}
_ => return Err(ApiKeyReject::BadChecksum),
}
let key_id = manager.extract_key_id(&secure_token);
let key_row: Option<ApiKeyRow> = {
let conn = db.read().map_err(|_| ApiKeyReject::Db)?;
conn.query_row(
"SELECT id, key_hash, user_id FROM api_keys WHERE key_id = ?1 AND revoked = 0 \
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
params![key_id],
|row| {
Ok(ApiKeyRow {
id: row.get(0)?,
hash: row.get(1)?,
user_id: row.get(2)?,
})
},
)
.ok()
};
let key_row = key_row.or_else(|| {
let conn = db.read().ok()?;
let mut stmt = conn
.prepare(
"SELECT id, key_hash, user_id FROM api_keys WHERE key_id IS NULL AND revoked = 0 \
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
)
.ok()?;
let rows: Vec<ApiKeyRow> = stmt
.query_map([], |row| {
Ok(ApiKeyRow {
id: row.get(0)?,
hash: row.get(1)?,
user_id: row.get(2)?,
})
})
.ok()?
.filter_map(|r| r.ok())
.collect();
for row in rows {
if let Ok(KeyStatus::Valid) = manager.verify(&secure_token, &row.hash) {
if let Ok(wconn) = db.write() {
let _ = wconn.execute(
"UPDATE api_keys SET key_id = ?1 WHERE id = ?2",
params![key_id, row.id],
);
}
return Some(row);
}
}
None
});
let Some(key) = key_row else {
return Err(ApiKeyReject::NotFound);
};
match manager.verify(&secure_token, &key.hash) {
Ok(KeyStatus::Valid) => {
let auth_user = match key.user_id {
None => None,
Some(uid) => {
let conn = db.read().map_err(|_| ApiKeyReject::Db)?;
match crate::db::queries::users::get_user_by_id(&conn, uid) {
Ok(u) => match crate::db::queries::users::credential_is_live(&conn, &u) {
Ok(true) => Some(crate::db::models::AuthUser {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
}),
Ok(false) => return Err(ApiKeyReject::Inactive),
Err(_) => return Err(ApiKeyReject::Db),
},
Err(_) => None,
}
}
};
Ok(auth_user)
}
_ => Err(ApiKeyReject::HashMismatch),
}
}
pub fn resolve_api_key_user(
db: &DbPool,
manager: &ApiKeyManagerV0,
token: &str,
) -> Result<Option<AuthUser>, String> {
validate_api_key(db, manager, token).map_err(|reject| match reject {
ApiKeyReject::Db => "database error".to_string(),
ApiKeyReject::BadChecksum => "invalid API key checksum".to_string(),
ApiKeyReject::NotFound => "invalid API key".to_string(),
ApiKeyReject::HashMismatch => "API key hash verification failed".to_string(),
ApiKeyReject::Inactive => "this account is deactivated".to_string(),
})
}
pub fn resolve_stdio_token(
db: &DbPool,
manager: &ApiKeyManagerV0,
) -> Result<Option<AuthUser>, String> {
let raw = std::env::var("LIFIC_TOKEN").unwrap_or_default();
let token = raw.trim();
if token.is_empty() {
return Ok(None);
}
resolve_api_key_user(db, manager, token)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use api_keys_simplified::SecureString;
use axum::{Extension, Router, middleware, routing::get};
use http_body_util::BodyExt;
use tower::ServiceExt;
fn test_db() -> db::DbPool {
db::open_memory().expect("test db")
}
use crate::test_env::lock_lific_token_env_blocking;
fn rfc3339_from_now(minutes: i64) -> String {
(chrono::Utc::now() + chrono::Duration::minutes(minutes)).to_rfc3339()
}
#[test]
fn an_iso8601_expiry_is_honoured_on_the_indexed_lookup() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let live =
create_api_key_with_expiry(&pool, &manager, "live", Some(&rfc3339_from_now(1)), None)
.unwrap();
let dead =
create_api_key_with_expiry(&pool, &manager, "dead", Some(&rfc3339_from_now(-1)), None)
.unwrap();
assert!(
validate_api_key(&pool, &manager, &live).is_ok(),
"a key expiring in a minute must still authenticate"
);
assert!(
validate_api_key(&pool, &manager, &dead).is_err(),
"a key that expired a minute ago must not authenticate"
);
}
#[test]
fn an_iso8601_expiry_is_honoured_on_the_legacy_null_key_id_path() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let live = create_api_key_with_expiry(
&pool,
&manager,
"legacy-live",
Some(&rfc3339_from_now(1)),
None,
)
.unwrap();
let dead = create_api_key_with_expiry(
&pool,
&manager,
"legacy-dead",
Some(&rfc3339_from_now(-1)),
None,
)
.unwrap();
pool.write()
.unwrap()
.execute("UPDATE api_keys SET key_id = NULL", [])
.unwrap();
assert!(
validate_api_key(&pool, &manager, &live).is_ok(),
"the legacy scan must honour a live ISO 8601 expiry"
);
assert!(
validate_api_key(&pool, &manager, &dead).is_err(),
"the legacy scan must reject an expired ISO 8601 key"
);
}
#[test]
fn a_key_without_an_expiry_still_authenticates() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "forever", None).unwrap();
assert!(validate_api_key(&pool, &manager, &key).is_ok());
}
#[test]
fn create_key_returns_valid_format() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "test-key", None).unwrap();
assert!(key.starts_with("lific_sk-live-"));
}
fn seed_key_owner(pool: &db::DbPool, username: &str) -> i64 {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: username.into(),
email: format!("{username}@test.local"),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
}
#[derive(Debug, PartialEq)]
struct KeyRow {
id: i64,
user_id: Option<i64>,
revoked: bool,
key_hash: String,
created_at: String,
}
fn key_rows(pool: &db::DbPool, name: &str) -> Vec<KeyRow> {
let conn = pool.read().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, user_id, revoked, key_hash, created_at
FROM api_keys WHERE name = ?1 ORDER BY id",
)
.unwrap();
let rows = stmt
.query_map(params![name], |r| {
Ok(KeyRow {
id: r.get(0)?,
user_id: r.get(1)?,
revoked: r.get(2)?,
key_hash: r.get(3)?,
created_at: r.get(4)?,
})
})
.unwrap();
rows.map(Result::unwrap).collect()
}
fn revoke_all(pool: &db::DbPool) {
pool.write()
.unwrap()
.execute("UPDATE api_keys SET revoked = 1", [])
.unwrap();
}
#[test]
fn the_same_bot_reclaims_its_own_revoked_name() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let bot = seed_key_owner(&pool, "opencode-blake");
let old = create_api_key(&pool, &manager, "opencode-blake", Some(bot)).unwrap();
revoke_all(&pool);
let fresh = create_api_key(&pool, &manager, "opencode-blake", Some(bot))
.expect("a bot reclaims its own revoked name");
assert_ne!(fresh, old);
let rows = key_rows(&pool, "opencode-blake");
assert_eq!(rows.len(), 1, "the dead row was swept, not accumulated");
assert_eq!(rows[0].user_id, Some(bot));
assert!(!rows[0].revoked);
assert!(validate_api_key(&pool, &manager, &old).is_err());
assert!(validate_api_key(&pool, &manager, &fresh).is_ok());
}
#[test]
fn a_human_reclaims_their_own_revoked_name() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let blake = seed_key_owner(&pool, "blake");
create_api_key(&pool, &manager, "laptop", Some(blake)).unwrap();
revoke_all(&pool);
let fresh = create_api_key(&pool, &manager, "laptop", Some(blake))
.expect("the same human reuses their own name");
assert!(validate_api_key(&pool, &manager, &fresh).is_ok());
assert_eq!(key_rows(&pool, "laptop")[0].user_id, Some(blake));
}
#[test]
fn an_unbound_key_reclaims_its_own_revoked_name() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "default", None).unwrap();
revoke_all(&pool);
let fresh = create_api_key(&pool, &manager, "default", None)
.expect("the operator key reuses its own name");
assert!(validate_api_key(&pool, &manager, &fresh).is_ok());
let rows = key_rows(&pool, "default");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].user_id, None);
}
#[test]
fn a_revoked_name_belonging_to_another_owner_is_refused_untouched() {
for claimant in ["different-human", "unbound", "bound"] {
let pool = test_db();
let manager = create_key_manager().unwrap();
let blake = seed_key_owner(&pool, "blake");
let mallory = seed_key_owner(&pool, "mallory");
let (holder, claimer) = match claimant {
"different-human" => (Some(blake), Some(mallory)),
"unbound" => (None, Some(mallory)),
"bound" => (Some(blake), None),
_ => unreachable!(),
};
create_api_key(&pool, &manager, "contested", holder).unwrap();
revoke_all(&pool);
let before = key_rows(&pool, "contested");
let err = match create_api_key(&pool, &manager, "contested", claimer) {
Ok(_) => panic!("{claimant}: another owner's tombstone must not be claimable"),
Err(err) => err,
};
match err {
crate::error::LificError::BadRequest(message) => assert!(
message.contains("reserved by another owner"),
"{claimant}: {message}"
),
other => panic!("{claimant}: expected BadRequest, got {other:?}"),
}
assert_eq!(
key_rows(&pool, "contested"),
before,
"{claimant}: the refusal must not touch the row or add one"
);
}
}
#[test]
fn an_active_name_is_still_refused_and_leaves_the_live_key_alone() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let owner = seed_key_owner(&pool, "blake");
let other = seed_key_owner(&pool, "mallory");
let live = create_api_key(&pool, &manager, "opencode-blake", Some(owner)).unwrap();
let before = key_rows(&pool, "opencode-blake");
for claimer in [Some(other), Some(owner), None] {
let err = create_api_key(&pool, &manager, "opencode-blake", claimer)
.expect_err("an active name is taken");
match err {
crate::error::LificError::BadRequest(message) => {
assert!(message.contains("already exists"), "{message}");
}
other => panic!("expected BadRequest, got {other:?}"),
}
}
assert_eq!(
key_rows(&pool, "opencode-blake"),
before,
"the refusal wrote nothing and did not rebind the live key"
);
assert!(
validate_api_key(&pool, &manager, &live).is_ok(),
"the live key still authenticates"
);
}
#[test]
fn reuse_matches_the_exact_name_only() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let owner = seed_key_owner(&pool, "blake");
create_api_key(&pool, &manager, "opencode-blake", Some(owner)).unwrap();
create_api_key(&pool, &manager, "opencode-blake-laptop", Some(owner)).unwrap();
let neighbour = create_api_key(&pool, &manager, "cursor-blake", Some(owner)).unwrap();
pool.write()
.unwrap()
.execute(
"UPDATE api_keys SET revoked = 1 WHERE name = 'opencode-blake'",
[],
)
.unwrap();
create_api_key(&pool, &manager, "opencode-blake", Some(owner)).unwrap();
assert_eq!(key_rows(&pool, "opencode-blake").len(), 1);
assert_eq!(
key_rows(&pool, "opencode-blake-laptop").len(),
1,
"a name this one is a prefix of is untouched"
);
assert!(validate_api_key(&pool, &manager, &neighbour).is_ok());
}
#[test]
fn sweeping_a_revoked_row_leaves_its_audit_trail_intact() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let user_id = {
let conn = pool.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "blake".into(),
email: "blake@test.local".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
user.id
};
create_api_key(&pool, &manager, "opencode-blake", Some(user_id)).unwrap();
{
let conn = pool.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, user_id).unwrap();
}
create_api_key(&pool, &manager, "opencode-blake", Some(user_id)).unwrap();
let conn = pool.read().unwrap();
let audited: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log
WHERE entity_type = 'api_key' AND action = 'revoke'
AND entity_label = 'opencode-blake'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(audited, 1, "the revocation is still on the record");
let leaked: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log WHERE new_value LIKE 'lific_sk%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(leaked, 0);
}
#[test]
fn rotation_replaces_a_live_key_and_keeps_its_owner() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let bot = seed_key_owner(&pool, "opencode-blake");
let old = create_api_key(&pool, &manager, "opencode-blake", Some(bot)).unwrap();
let fresh = rotate_api_key(&pool, &manager, "opencode-blake").unwrap();
assert_ne!(fresh, old);
let rows = key_rows(&pool, "opencode-blake");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].user_id, Some(bot), "the binding carried over");
assert!(validate_api_key(&pool, &manager, &old).is_err());
assert!(validate_api_key(&pool, &manager, &fresh).is_ok());
}
#[test]
fn rotation_of_a_missing_name_is_not_found_and_writes_nothing() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let err = rotate_api_key(&pool, &manager, "never-existed").expect_err("nothing to rotate");
assert!(matches!(err, crate::error::LificError::NotFound(_)));
assert!(key_rows(&pool, "never-existed").is_empty());
}
mod lockdown_race {
use super::*;
use std::sync::mpsc;
fn seed(pool: &db::DbPool) -> (i64, String) {
let conn = pool.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "blake".into(),
email: "blake@test.local".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let session = crate::db::queries::users::create_session(&conn, user.id, None).unwrap();
(user.id, session.token)
}
fn writer_is_held(path: &std::path::Path) -> bool {
let probe = rusqlite::Connection::open(path).expect("probe connection");
probe
.busy_timeout(std::time::Duration::ZERO)
.expect("disable the busy handler");
match probe.execute_batch("BEGIN IMMEDIATE") {
Ok(()) => {
probe.execute_batch("ROLLBACK").expect("release the probe");
false
}
Err(rusqlite::Error::SqliteFailure(error, _))
if matches!(
error.code,
rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
) =>
{
true
}
Err(other) => panic!("probe failed for a reason other than contention: {other:?}"),
}
}
fn active_keys(pool: &db::DbPool, name: &str) -> i64 {
pool.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM api_keys WHERE name = ?1 AND revoked = 0",
params![name],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn a_lockdown_cannot_slip_inside_a_credential_mint() {
let dir = tempfile::tempdir().expect("scratch dir");
let path = dir.path().join("lific.db");
let minting = db::open(&path).expect("minting pool");
let recovering = db::open(&path).expect("recovering pool");
let (user_id, session) = seed(&minting);
let manager = create_key_manager().unwrap();
let prepared = PreparedApiKey::generate(&manager).unwrap();
assert!(
!writer_is_held(&path),
"control: with nobody writing, the probe must be able to take the lock"
);
let (inserted_tx, inserted_rx) = mpsc::channel::<()>();
let (probe_tx, probe_rx) = mpsc::channel::<bool>();
let (minting, session) = (&minting, &session);
let (key, held_during_mint) = std::thread::scope(|scope| {
let mint = scope.spawn(move || {
let held = std::cell::Cell::new(false);
let key = minting.transaction(|tx| {
revalidate_recent_session(tx, session, user_id)?;
let plaintext = prepared.insert(tx, "racing", None, Some(user_id))?;
inserted_tx.send(()).expect("announce the insert");
held.set(probe_rx.recv().expect("probe result"));
Ok(plaintext)
});
(key.expect("the mint commits"), held.get())
});
inserted_rx.recv().expect("the mint reached its insert");
probe_tx
.send(writer_is_held(&path))
.expect("hand the probe result back");
mint.join().expect("mint thread")
});
assert!(
held_during_mint,
"the write slot must be taken for the whole mint transaction"
);
assert_eq!(active_keys(minting, "racing"), 1, "the mint committed");
recovering
.transaction(|tx| crate::db::queries::users::lock_down_account(tx, user_id))
.expect("the lockdown commits once the writer is free");
assert!(
validate_api_key(&recovering, &manager, &key).is_err(),
"a key minted immediately before a lockdown is inside its blast radius"
);
assert_eq!(active_keys(&recovering, "racing"), 0);
}
#[test]
fn a_credential_mint_cannot_slip_inside_a_lockdown() {
let dir = tempfile::tempdir().expect("scratch dir");
let path = dir.path().join("lific.db");
let minting = db::open(&path).expect("minting pool");
let recovering = db::open(&path).expect("recovering pool");
let (user_id, session) = seed(&minting);
let manager = create_key_manager().unwrap();
let prepared = PreparedApiKey::generate(&manager).unwrap();
assert!(
!writer_is_held(&path),
"control: with nobody writing, the probe must be able to take the lock"
);
let (locked_tx, locked_rx) = mpsc::channel::<()>();
let (probe_tx, probe_rx) = mpsc::channel::<bool>();
let recovering_ref = &recovering;
let held_during_lockdown = std::thread::scope(|scope| {
let recovery = scope.spawn(move || {
let held = std::cell::Cell::new(false);
recovering_ref
.transaction(|tx| {
crate::db::queries::users::lock_down_account(tx, user_id)?;
locked_tx.send(()).expect("announce the lockdown");
held.set(probe_rx.recv().expect("probe result"));
Ok(())
})
.expect("the lockdown commits");
held.get()
});
locked_rx.recv().expect("the lockdown reached its writes");
probe_tx
.send(writer_is_held(&path))
.expect("hand the probe result back");
recovery.join().expect("recovery thread")
});
assert!(
held_during_lockdown,
"the write slot must be taken for the whole lockdown transaction"
);
let outcome = minting.transaction(|tx| {
revalidate_recent_session(tx, &session, user_id)?;
prepared.insert(tx, "racing", None, Some(user_id))
});
assert!(
matches!(outcome, Err(crate::error::LificError::Forbidden(_))),
"revalidation must refuse a session the lockdown deleted: {outcome:?}"
);
assert_eq!(
active_keys(&minting, "racing"),
0,
"the refused mint wrote nothing"
);
}
}
#[test]
fn verify_key_succeeds() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "test-key", None).unwrap();
let keys = list_api_keys(&pool).unwrap();
assert_eq!(keys.len(), 1);
let secure_key = SecureString::from(key);
let conn = pool.read().unwrap();
let hash: String = conn
.query_row(
"SELECT key_hash FROM api_keys WHERE name = 'test-key'",
[],
|row| row.get(0),
)
.unwrap();
let status = manager.verify(&secure_key, &hash).unwrap();
assert!(matches!(status, KeyStatus::Valid));
}
#[test]
fn wrong_key_fails() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "test-key", None).unwrap();
let conn = pool.read().unwrap();
let hash: String = conn
.query_row(
"SELECT key_hash FROM api_keys WHERE name = 'test-key'",
[],
|row| row.get(0),
)
.unwrap();
let wrong_key = SecureString::from(
"lific_sk-live-AAAAAAAAAAAAAAAAAAAAAAAAAAAA.0000000000000000".to_string(),
);
let status = manager.verify(&wrong_key, &hash);
if let Ok(KeyStatus::Valid) = status {
panic!("wrong key should not validate");
}
}
#[test]
fn revoke_key_works() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "revoke-me", None).unwrap();
revoke_api_key(&pool, "revoke-me").unwrap();
let keys = list_api_keys(&pool).unwrap();
assert!(keys[0].revoked);
}
#[test]
fn rotate_key_replaces_old() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let old_key = create_api_key(&pool, &manager, "rotate-me", None).unwrap();
let new_key = rotate_api_key(&pool, &manager, "rotate-me").unwrap();
assert_ne!(old_key, new_key);
assert!(new_key.starts_with("lific_sk-live-"));
let keys = list_api_keys(&pool).unwrap();
assert_eq!(keys.len(), 1);
assert!(!keys[0].revoked);
}
#[test]
fn created_key_is_bound_by_the_insert() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let user_id = {
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('owner', 'owner@test.local', 'x', 'Owner', 0, 0)",
[],
)
.unwrap();
conn.last_insert_rowid()
};
create_api_key(&pool, &manager, "owned", Some(user_id)).unwrap();
create_api_key_with_expiry(&pool, &manager, "dated", Some("2030-06-01"), Some(user_id))
.unwrap();
let conn = pool.read().unwrap();
for name in ["owned", "dated"] {
let bound: Option<i64> = conn
.query_row(
"SELECT user_id FROM api_keys WHERE name = ?1",
params![name],
|row| row.get(0),
)
.unwrap();
assert_eq!(bound, Some(user_id), "key '{name}' must be created bound");
}
create_api_key(&pool, &manager, "anonymous", None).unwrap();
let bound: Option<i64> = conn
.query_row(
"SELECT user_id FROM api_keys WHERE name = 'anonymous'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(bound, None);
}
#[test]
fn rotate_bound_overrides_the_previous_binding() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "claude-code-owner", None).unwrap();
let bot_id = {
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('claude-code', 'bot@test.local', 'x', 'Claude Code', 0, 1)",
[],
)
.unwrap();
conn.last_insert_rowid()
};
rotate_api_key_bound(&pool, &manager, "claude-code-owner", Some(bot_id)).unwrap();
let conn = pool.read().unwrap();
let bound: Option<i64> = conn
.query_row(
"SELECT user_id FROM api_keys WHERE name = 'claude-code-owner' AND revoked = 0",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(bound, Some(bot_id));
}
#[test]
fn rotate_key_preserves_user_binding() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let user_id = {
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('bot', 'bot@test.local', 'x', 'Bot', 0, 1)",
[],
)
.unwrap();
conn.last_insert_rowid()
};
create_api_key(&pool, &manager, "bot-key", Some(user_id)).unwrap();
rotate_api_key(&pool, &manager, "bot-key").unwrap();
let conn = pool.read().unwrap();
let bound: Option<i64> = conn
.query_row(
"SELECT user_id FROM api_keys WHERE name = 'bot-key' AND revoked = 0",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
bound,
Some(user_id),
"rotated key must keep its user binding"
);
}
#[test]
fn rotate_unbound_key_stays_unbound() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "plain", None).unwrap();
rotate_api_key(&pool, &manager, "plain").unwrap();
let conn = pool.read().unwrap();
let bound: Option<i64> = conn
.query_row(
"SELECT user_id FROM api_keys WHERE name = 'plain' AND revoked = 0",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(bound, None);
}
#[test]
fn duplicate_name_rejected() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "unique", None).unwrap();
let result = create_api_key(&pool, &manager, "unique", None);
assert!(result.is_err());
}
#[test]
fn has_any_keys_works() {
let pool = test_db();
assert!(!has_any_keys(&pool));
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "first", None).unwrap();
assert!(has_any_keys(&pool));
}
#[test]
fn should_mint_initial_key_empty_bootstrap_mints() {
let pool = test_db();
assert!(should_mint_initial_key(&pool));
}
#[test]
fn should_mint_initial_key_false_once_a_human_operator_exists() {
let pool = test_db();
let conn = pool.write().unwrap();
crate::db::queries::users::create_passwordless_admin(&conn, "Blake").unwrap();
drop(conn);
assert!(!should_mint_initial_key(&pool));
}
#[test]
fn should_mint_initial_key_false_when_any_key_exists() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "first", None).unwrap();
assert!(!should_mint_initial_key(&pool));
}
#[test]
fn create_key_stores_key_id() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "id-test", None).unwrap();
let conn = pool.read().unwrap();
let stored_key_id: Option<String> = conn
.query_row(
"SELECT key_id FROM api_keys WHERE name = 'id-test'",
[],
|row| row.get(0),
)
.unwrap();
let key_id = stored_key_id.expect("key_id should be stored");
assert_eq!(key_id.len(), 32);
assert!(key_id.chars().all(|c| c.is_ascii_hexdigit()));
let secure_key = SecureString::from(key);
let extracted_id = manager.extract_key_id(&secure_key);
assert_eq!(extracted_id, key_id);
}
#[test]
fn key_id_lookup_finds_correct_key() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key1 = create_api_key(&pool, &manager, "key-1", None).unwrap();
let _key2 = create_api_key(&pool, &manager, "key-2", None).unwrap();
let secure_key = SecureString::from(key1);
let key_id = manager.extract_key_id(&secure_key);
let conn = pool.read().unwrap();
let found_name: String = conn
.query_row(
"SELECT name FROM api_keys WHERE key_id = ?1 AND revoked = 0",
params![key_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(found_name, "key-1");
}
#[test]
fn legacy_key_without_key_id_still_verifiable() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "legacy", None).unwrap();
let conn = pool.write().unwrap();
conn.execute(
"UPDATE api_keys SET key_id = NULL WHERE name = 'legacy'",
[],
)
.unwrap();
drop(conn);
let secure_key = SecureString::from(key);
let conn = pool.read().unwrap();
let hash: String = conn
.query_row(
"SELECT key_hash FROM api_keys WHERE name = 'legacy'",
[],
|row| row.get(0),
)
.unwrap();
let status = manager.verify(&secure_key, &hash).unwrap();
assert!(matches!(status, KeyStatus::Valid));
}
fn test_auth_state(pool: &db::DbPool) -> AuthState {
AuthState {
db: pool.clone(),
manager: create_key_manager().unwrap(),
public_url: "https://example.com".into(),
required: true,
}
}
fn echo_app(auth_state: AuthState) -> Router {
async fn echo(
Extension(auth_user): Extension<Option<crate::db::models::AuthUser>>,
) -> String {
match auth_user {
Some(u) => format!("user:{}:{}:{}", u.id, u.username, u.is_admin),
None => "none".to_string(),
}
}
Router::new()
.route("/echo", get(echo))
.layer(middleware::from_fn_with_state(auth_state, require_api_key))
}
fn insert_oauth_token(pool: &db::DbPool, suffix: &str, user_id: Option<i64>) -> String {
let token = format!("lific_at_test-{suffix}");
let hash = sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let client_id = format!("client-{suffix}");
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?1, 'Test', '[\"http://localhost\"]')",
params![client_id],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id) VALUES (?1, ?2, ?3, 'mcp', ?4)",
params![hash, client_id, expires, user_id],
)
.unwrap();
token
}
#[tokio::test]
async fn oauth_token_rest_request_resolves_to_correct_auth_user() {
let pool = test_db();
let user_id = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "tokenuser".into(),
email: "tokenuser@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Token User".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
};
let token = insert_oauth_token(&pool, "resolves", Some(user_id));
let resp = echo_app(test_auth_state(&pool))
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
bytes.as_ref(),
format!("user:{user_id}:tokenuser:false").as_bytes(),
"OAuth token must resolve to the bound user, not None"
);
}
#[tokio::test]
async fn oauth_token_bound_to_bot_resolves_to_the_bot_identity() {
let pool = test_db();
let bot_id = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "claude-code-blake".into(),
email: "claude-code-blake@bot.local".into(),
password: "testpassword1".into(),
display_name: Some("Claude Code".into()),
is_admin: false,
is_bot: true,
},
)
.unwrap()
.id
};
let token = insert_oauth_token(&pool, "bot", Some(bot_id));
let resp = identity_echo_app(test_auth_state(&pool))
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8(bytes.as_ref().to_vec()).unwrap();
assert!(
body.contains(&format!("id:{bot_id}:claude-code-blake")),
"OAuth token must resolve to the per-tool bot, got: {body}"
);
}
#[tokio::test]
async fn legacy_api_key_without_user_resolves_to_none_via_middleware() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "legacy-plain", None).unwrap();
let resp = echo_app(test_auth_state(&pool))
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {key}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
bytes.as_ref(),
b"none",
"a legacy key with no bound user must stay unresolved (default-deny)"
);
}
fn owner_and_bot(pool: &db::DbPool) -> (i64, i64) {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "instance-admin".into(),
email: "instance-admin@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let owner = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "botowner".into(),
email: "botowner@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Bot Owner".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap();
let bot =
crate::db::queries::users::ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap();
(owner.id, bot.id)
}
fn set_owner_active(pool: &db::DbPool, owner_id: i64, active: bool) {
let conn = pool.write().unwrap();
crate::db::queries::users::set_active(&conn, owner_id, active).unwrap();
}
async fn echo_with_bearer(pool: &db::DbPool, token: &str) -> (StatusCode, String) {
let resp = echo_app(test_auth_state(pool))
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8(bytes.to_vec()).unwrap())
}
#[tokio::test]
async fn bot_api_key_stops_working_while_its_owner_is_deactivated() {
let pool = test_db();
let (owner_id, bot_id) = owner_and_bot(&pool);
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "opencode-key", Some(bot_id)).unwrap();
let (status, body) = echo_with_bearer(&pool, &key).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, format!("user:{bot_id}:opencode-botowner:false"));
set_owner_active(&pool, owner_id, false);
let (status, body) = echo_with_bearer(&pool, &key).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"a bot must not outlive its owner's access: {body}"
);
assert_ne!(
body, "none",
"and must never degrade to the unbound-key operator fallback"
);
set_owner_active(&pool, owner_id, true);
let (status, body) = echo_with_bearer(&pool, &key).await;
assert_eq!(
status,
StatusCode::OK,
"reactivating the owner restores the bot without re-minting: {body}"
);
assert_eq!(body, format!("user:{bot_id}:opencode-botowner:false"));
}
#[tokio::test]
async fn bot_oauth_token_stops_working_while_its_owner_is_deactivated() {
let pool = test_db();
let (owner_id, bot_id) = owner_and_bot(&pool);
let token = insert_oauth_token(&pool, "ownerdeact", Some(bot_id));
let (status, body) = echo_with_bearer(&pool, &token).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, format!("user:{bot_id}:opencode-botowner:false"));
set_owner_active(&pool, owner_id, false);
let (status, body) = echo_with_bearer(&pool, &token).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"the bot's OAuth token dies with its owner's access: {body}"
);
set_owner_active(&pool, owner_id, true);
let (status, body) = echo_with_bearer(&pool, &token).await;
assert_eq!(status, StatusCode::OK, "restored on reactivation: {body}");
assert_eq!(body, format!("user:{bot_id}:opencode-botowner:false"));
}
#[tokio::test]
async fn api_key_bound_to_a_deactivated_human_is_refused_even_unrevoked() {
let pool = test_db();
let (owner_id, _bot_id) = owner_and_bot(&pool);
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "human-key", Some(owner_id)).unwrap();
{
let conn = pool.write().unwrap();
conn.execute(
"UPDATE users SET is_active = 0 WHERE id = ?1",
params![owner_id],
)
.unwrap();
}
let (status, body) = echo_with_bearer(&pool, &key).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_ne!(body, "none", "never falls back to the operator");
}
#[tokio::test]
async fn ownerless_bot_key_is_unaffected_by_the_owner_check() {
let pool = test_db();
let bot_id = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "orphan-bot".into(),
email: "orphan-bot@bot.local".into(),
password: "testpassword1".into(),
display_name: Some("Orphan".into()),
is_admin: false,
is_bot: true,
},
)
.unwrap()
.id
};
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "orphan-key", Some(bot_id)).unwrap();
let (status, body) = echo_with_bearer(&pool, &key).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, format!("user:{bot_id}:orphan-bot:false"));
}
fn operator_probe_app(auth_state: AuthState, pool: db::DbPool) -> Router {
Router::new()
.route(
"/probe",
get(
move |Extension(identity): Extension<
Option<crate::resolve_caller::ResolvedIdentity>,
>| {
let pool = pool.clone();
async move {
match crate::authz::visible_project_ids(&pool, &identity).unwrap() {
None => "unrestricted".to_string(),
Some(ids) => format!("restricted:{}", ids.len()),
}
}
},
),
)
.layer(middleware::from_fn_with_state(auth_state, require_api_key))
}
#[tokio::test]
async fn auth_not_required_credentialless_request_passes_as_operator() {
let pool = test_db();
seed_admin(&pool, "admin"); enable_enforcement(&pool);
let mut state = test_auth_state(&pool);
state.required = false;
let resp = operator_probe_app(state, pool.clone())
.oneshot(
Request::builder()
.uri("/probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
bytes.as_ref(),
b"unrestricted",
"with [auth] required=false, an anonymous request must carry the operator bypass"
);
}
#[tokio::test]
async fn auth_required_default_credentialless_request_still_401s() {
let pool = test_db();
let resp =
echo_app(test_auth_state(&pool)) .oneshot(Request::builder().uri("/echo").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_not_required_presented_invalid_tokens_still_401() {
let pool = test_db();
let mut state = test_auth_state(&pool);
state.required = false;
for bad in [
"lific_sk-garbage", "lific_sess_expiredorfake", "lific_at_neverissued", ] {
let resp = echo_app(state.clone())
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {bad}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"presented-but-invalid credential '{bad}' must 401 even with auth optional"
);
}
}
#[tokio::test]
async fn auth_not_required_valid_session_still_resolves_user() {
let pool = test_db();
let (token, user_id) = {
let conn = pool.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "optionaluser".into(),
email: "optional@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let token = crate::db::queries::users::create_session(&conn, user.id, None)
.unwrap()
.token;
(token, user.id)
};
let mut state = test_auth_state(&pool);
state.required = false;
let resp = echo_app(state)
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
bytes.as_ref(),
format!("user:{user_id}:optionaluser:false").as_bytes()
);
}
#[tokio::test]
async fn oauth_token_for_deleted_user_is_rejected() {
let pool = test_db();
let user_id = {
let conn = pool.write().unwrap();
let id = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "ghost".into(),
email: "ghost@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id;
conn.execute("DELETE FROM users WHERE id = ?1", params![id])
.unwrap();
id
};
let token = insert_oauth_token(&pool, "ghost", Some(user_id));
let resp = echo_app(test_auth_state(&pool))
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
fn set_key_expiry(pool: &db::DbPool, name: &str, expires_at: &str) {
let conn = pool.write().unwrap();
conn.execute(
"UPDATE api_keys SET expires_at = ?1 WHERE name = ?2",
params![expires_at, name],
)
.unwrap();
}
async fn auth_status(pool: &db::DbPool, key: &str) -> StatusCode {
echo_app(test_auth_state(pool))
.oneshot(
Request::builder()
.uri("/echo")
.header("authorization", format!("Bearer {key}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn expired_key_id_lookup_is_rejected() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "expired", None).unwrap();
set_key_expiry(&pool, "expired", "2000-01-01T00:00:00Z");
assert_eq!(
auth_status(&pool, &key).await,
StatusCode::UNAUTHORIZED,
"an expired key must not authenticate (key_id lookup path)"
);
}
#[tokio::test]
async fn unexpired_key_authenticates() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "future", None).unwrap();
set_key_expiry(&pool, "future", "2999-12-31T23:59:59Z");
assert_eq!(
auth_status(&pool, &key).await,
StatusCode::OK,
"a key with a future expiry must still authenticate"
);
}
#[tokio::test]
async fn null_expiry_authenticates() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "forever", None).unwrap();
assert_eq!(
auth_status(&pool, &key).await,
StatusCode::OK,
"a NULL expires_at means the key never expires (unchanged behavior)"
);
}
#[tokio::test]
async fn expired_legacy_key_without_key_id_is_rejected() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "legacy-expired", None).unwrap();
{
let conn = pool.write().unwrap();
conn.execute(
"UPDATE api_keys SET key_id = NULL, expires_at = '2000-01-01T00:00:00Z' \
WHERE name = 'legacy-expired'",
[],
)
.unwrap();
}
assert_eq!(
auth_status(&pool, &key).await,
StatusCode::UNAUTHORIZED,
"an expired legacy key must not authenticate (NULL key_id scan path)"
);
}
#[test]
fn create_api_key_with_expiry_writes_column() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key_with_expiry(&pool, &manager, "dated", Some("2030-06-01"), None).unwrap();
let conn = pool.read().unwrap();
let stored: Option<String> = conn
.query_row(
"SELECT expires_at FROM api_keys WHERE name = 'dated'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored.as_deref(), Some("2030-06-01"));
}
fn enable_enforcement(pool: &db::DbPool) {
let conn = pool.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
authz_enforced: Some(true),
..Default::default()
},
)
.unwrap();
}
fn seed_project_id(pool: &db::DbPool, ident: &str) -> i64 {
let conn = pool.write().unwrap();
crate::db::queries::create_project(
&conn,
&crate::db::models::CreateProject {
name: format!("Project {ident}"),
identifier: ident.into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap()
.id
}
fn gate_app(auth_state: AuthState, pool: db::DbPool, project_id: i64) -> Router {
async fn gate(
State((pool, project_id)): State<(db::DbPool, i64)>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> Result<String, crate::error::LificError> {
crate::authz::require_role(
&pool,
&identity,
project_id,
crate::db::models::Role::Viewer,
)?;
Ok("allowed".into())
}
Router::new()
.route("/gate", get(gate))
.with_state((pool, project_id))
.layer(middleware::from_fn_with_state(auth_state, require_api_key))
}
async fn gate_status(app: Router, key: &str) -> StatusCode {
app.oneshot(
Request::builder()
.uri("/gate")
.header("authorization", format!("Bearer {key}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn enforced_operator_unbound_key_passes_viewer_gate_via_middleware() {
let pool = test_db();
seed_admin(&pool, "admin"); let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "operator", None).unwrap(); let project = seed_project_id(&pool, "OPM");
enable_enforcement(&pool);
let app = gate_app(test_auth_state(&pool), pool.clone(), project);
assert_eq!(
gate_status(app, &key).await,
StatusCode::OK,
"an unbound (operator) API key must pass the Viewer gate in enforced mode"
);
}
#[tokio::test]
async fn enforced_legacy_unbound_oauth_token_is_forbidden_via_middleware() {
let pool = test_db();
let project = seed_project_id(&pool, "OAM");
enable_enforcement(&pool);
let token = insert_oauth_token(&pool, "legacy-unbound", None);
let app = gate_app(test_auth_state(&pool), pool.clone(), project);
assert_eq!(
gate_status(app, &token).await,
StatusCode::FORBIDDEN,
"with no admin to resolve to, a credential-less request stays default-denied"
);
}
#[tokio::test]
async fn enforced_user_bound_key_nonmember_is_forbidden_via_middleware() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = {
let uid = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "bounduser".into(),
email: "bound@test.local".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
};
create_api_key(&pool, &manager, "bound", Some(uid)).unwrap()
};
let project = seed_project_id(&pool, "BNM");
enable_enforcement(&pool);
let app = gate_app(test_auth_state(&pool), pool.clone(), project);
assert_eq!(
gate_status(app, &key).await,
StatusCode::FORBIDDEN,
"a user-bound key for a non-member must be denied — it is not an operator credential"
);
}
#[test]
fn is_attachment_download_matches_numeric_id_get() {
assert!(is_attachment_download(&Method::GET, "/api/attachments/5"));
assert!(is_attachment_download(
&Method::GET,
"/api/attachments/12345"
));
}
#[test]
fn is_attachment_download_tolerates_trailing_slash() {
assert!(is_attachment_download(&Method::GET, "/api/attachments/7/"));
}
#[test]
fn is_attachment_download_excludes_list_route() {
assert!(!is_attachment_download(&Method::GET, "/api/attachments"));
assert!(!is_attachment_download(&Method::GET, "/api/attachments/"));
}
#[test]
fn is_attachment_download_excludes_non_numeric_and_deeper_paths() {
assert!(!is_attachment_download(
&Method::GET,
"/api/attachments/abc"
));
assert!(!is_attachment_download(
&Method::GET,
"/api/attachments/5/extra"
));
assert!(!is_attachment_download(&Method::GET, "/api/attachments/5x"));
}
#[test]
fn is_attachment_download_includes_the_thumbnail_variant() {
assert!(is_attachment_download(
&Method::GET,
"/api/attachments/5/thumbnail"
));
assert!(is_attachment_download(
&Method::GET,
"/api/attachments/5/thumbnail/"
));
assert!(!is_attachment_download(
&Method::GET,
"/api/attachments/5/links"
));
assert!(!is_attachment_download(
&Method::GET,
"/api/attachments/5/preview"
));
assert!(!is_attachment_download(
&Method::GET,
"/api/attachments/abc/thumbnail"
));
assert!(!is_attachment_download(
&Method::POST,
"/api/attachments/5/thumbnail"
));
}
#[test]
fn is_attachment_download_excludes_non_get_methods() {
assert!(!is_attachment_download(
&Method::DELETE,
"/api/attachments/5"
));
assert!(!is_attachment_download(&Method::POST, "/api/attachments/5"));
}
#[test]
fn session_cookie_token_extracts_only_session_prefix() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
"foo=bar; lific_token=lific_sess_abc123; baz=qux"
.parse()
.unwrap(),
);
assert_eq!(
session_cookie_token(&headers).as_deref(),
Some("lific_sess_abc123")
);
}
#[test]
fn session_cookie_token_rejects_non_session_values() {
for value in ["lific_sk-live-xxx", "lific_at_xxx", "garbage"] {
let mut headers = HeaderMap::new();
headers.insert("cookie", format!("lific_token={value}").parse().unwrap());
assert_eq!(
session_cookie_token(&headers),
None,
"non-session cookie value must be rejected: {value}"
);
}
}
#[test]
fn session_cookie_token_none_when_absent() {
let headers = HeaderMap::new();
assert_eq!(session_cookie_token(&headers), None);
let mut headers = HeaderMap::new();
headers.insert("cookie", "other=1; another=2".parse().unwrap());
assert_eq!(session_cookie_token(&headers), None);
}
fn seed_admin(pool: &db::DbPool, username: &str) -> i64 {
let conn = pool.write().unwrap();
let u = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: username.into(),
email: format!("{username}@local.test"),
password: "adminpass123".into(),
display_name: Some(format!("Admin {username}")),
is_admin: true,
is_bot: false,
},
)
.unwrap();
u.id
}
fn identity_echo_app(auth_state: AuthState) -> Router {
async fn echo(
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> String {
match identity {
Some(id) => format!(
"id:{}:{}:{}:{}",
id.user.id,
id.user.username,
id.user.is_admin,
id.transport.as_str()
),
None => "none".to_string(),
}
}
Router::new()
.route("/echo", get(echo))
.layer(middleware::from_fn_with_state(auth_state, require_api_key))
}
async fn identity_body(app: Router, auth: Option<&str>) -> String {
let mut req = Request::builder().uri("/echo");
if let Some(token) = auth {
req = req.header("authorization", format!("Bearer {token}"));
}
let resp = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.as_ref().to_vec()).unwrap()
}
#[tokio::test]
async fn resolved_identity_session_token_is_the_user_on_web_transport() {
let pool = test_db();
let (token, user_id) = {
let conn = pool.write().unwrap();
let u = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "sessuser".into(),
email: "sessuser@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let token = crate::db::queries::users::create_session(&conn, u.id, None)
.unwrap()
.token;
(token, u.id)
};
let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&token)).await;
assert_eq!(
body,
format!("id:{user_id}:sessuser:false:web"),
"session token must resolve to its user on the web transport"
);
}
#[tokio::test]
async fn resolved_identity_bound_api_key_resolves_to_bound_user() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let user_id = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "keyuser".into(),
email: "keyuser@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
};
let key = create_api_key(&pool, &manager, "bound", Some(user_id)).unwrap();
let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&key)).await;
assert_eq!(
body,
format!("id:{user_id}:keyuser:false:api"),
"a user-bound API key must resolve to that user on the api transport"
);
}
#[tokio::test]
async fn resolved_identity_bound_oauth_token_resolves_to_bound_user() {
let pool = test_db();
let user_id = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "oauthuser".into(),
email: "oauthuser@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
};
let token = insert_oauth_token(&pool, "bound-oauth", Some(user_id));
let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&token)).await;
assert_eq!(
body,
format!("id:{user_id}:oauthuser:false:api"),
"a user-bound OAuth token must resolve to that user on the api transport"
);
}
#[tokio::test]
async fn resolved_identity_legacy_unbound_oauth_falls_back_to_first_admin() {
let pool = test_db();
let admin_id = seed_admin(&pool, "admin");
let token = insert_oauth_token(&pool, "legacy-unbound-id", None);
let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&token)).await;
assert_eq!(
body,
format!("id:{admin_id}:admin:true:api"),
"a legacy unbound OAuth token must resolve to the first admin via the fallback"
);
}
#[tokio::test]
async fn resolved_identity_unbound_api_key_falls_back_to_first_admin() {
let pool = test_db();
let admin_id = seed_admin(&pool, "admin");
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "operator", None).unwrap();
let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&key)).await;
assert_eq!(
body,
format!("id:{admin_id}:admin:true:api"),
"an unbound (operator) API key must resolve to the first admin"
);
}
#[tokio::test]
async fn resolved_identity_auth_off_credentialless_falls_back_to_first_admin() {
let pool = test_db();
let admin_id = seed_admin(&pool, "admin");
let mut state = test_auth_state(&pool);
state.required = false;
let body = identity_body(identity_echo_app(state), None).await;
assert_eq!(
body,
format!("id:{admin_id}:admin:true:api"),
"auth-off credential-less request must resolve to the first admin"
);
}
#[tokio::test]
async fn resolved_identity_auth_off_zero_users_inserts_no_identity() {
let pool = test_db(); let mut state = test_auth_state(&pool);
state.required = false;
let body = identity_body(identity_echo_app(state), None).await;
assert_eq!(body, "none", "zero-user bootstrap inserts no identity");
}
#[test]
fn resolve_api_key_user_bound_key_returns_user() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let uid = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "agent-user".into(),
email: "agent-user@local.test".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: true,
},
)
.unwrap()
.id
};
let key = create_api_key(&pool, &manager, "opencode-agent", Some(uid)).unwrap();
let resolved = resolve_api_key_user(&pool, &manager, &key).unwrap();
let user = resolved.expect("bound key resolves to a user");
assert_eq!(user.id, uid);
assert_eq!(user.username, "agent-user");
}
#[test]
fn resolve_api_key_user_unbound_key_is_ok_none() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "unbound", None).unwrap();
let resolved = resolve_api_key_user(&pool, &manager, &key).unwrap();
assert!(
resolved.is_none(),
"a valid-but-unbound key must be Ok(None) — operator fallback"
);
}
#[test]
fn resolve_api_key_user_invalid_key_is_err() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let err = resolve_api_key_user(&pool, &manager, "lific_sk-live-NOTAREALKEY")
.expect_err("a bogus key must be an error");
assert!(!err.is_empty());
}
#[test]
fn resolve_stdio_token_without_env_is_ok_none() {
let _guard = lock_lific_token_env_blocking();
let pool = test_db();
let manager = create_key_manager().unwrap();
unsafe { std::env::remove_var("LIFIC_TOKEN") };
let resolved = resolve_stdio_token(&pool, &manager).unwrap();
assert!(resolved.is_none(), "absent token must be Ok(None)");
}
#[test]
fn resolve_stdio_token_valid_env_resolves_bound_user() {
let _guard = lock_lific_token_env_blocking();
let pool = test_db();
let manager = create_key_manager().unwrap();
let uid = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "stdio-agent".into(),
email: "stdio-agent@local.test".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: true,
},
)
.unwrap()
.id
};
let key = create_api_key(&pool, &manager, "codex-agent", Some(uid)).unwrap();
unsafe { std::env::set_var("LIFIC_TOKEN", &key) };
let resolved = resolve_stdio_token(&pool, &manager).unwrap();
unsafe { std::env::remove_var("LIFIC_TOKEN") };
assert_eq!(resolved.unwrap().id, uid);
}
fn real_api_app(pool: &db::DbPool) -> Router {
let state = test_auth_state(pool);
let manager = std::sync::Arc::new(state.manager.clone());
crate::api::router(pool.clone(), &[])
.layer(Extension(manager))
.layer(Extension(crate::realtime::RealtimeHub::new()))
.layer(Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(middleware::from_fn_with_state(state, require_api_key))
}
async fn send(
app: Router,
method: &str,
uri: &str,
body: Option<serde_json::Value>,
token: &str,
) -> (StatusCode, String) {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header("authorization", format!("Bearer {token}"));
let body = match body {
Some(json) => {
builder = builder.header("content-type", "application/json");
Body::from(serde_json::to_vec(&json).unwrap())
}
None => Body::empty(),
};
let resp = app.oneshot(builder.body(body).unwrap()).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
fn seed_user(pool: &db::DbPool, username: &str) -> i64 {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: username.into(),
email: format!("{username}@test.local"),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
}
#[tokio::test]
async fn oauth_token_cannot_mint_an_api_key() {
let pool = test_db();
let uid = seed_user(&pool, "toolowner");
let token = insert_oauth_token(&pool, "mint", Some(uid));
let (status, body) = send(
real_api_app(&pool),
"POST",
"/api/auth/keys",
Some(serde_json::json!({ "name": "stolen" })),
&token,
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"an OAuth token must not reach the key-mint route: {body}"
);
assert!(
body.contains("API key management"),
"the 403 must say why: {body}"
);
assert!(
list_api_keys(&pool).unwrap().is_empty(),
"no key may have been created"
);
}
#[tokio::test]
async fn oauth_token_is_refused_on_every_credential_management_route() {
let pool = test_db();
let uid = seed_user(&pool, "toolowner2");
let token = insert_oauth_token(&pool, "surface", Some(uid));
let cases: [(&str, &str, Option<serde_json::Value>); 9] = [
("GET", "/api/auth/keys", None),
("DELETE", "/api/auth/keys/1", None),
("GET", "/api/auth/bots", None),
("POST", "/api/auth/bots", Some(serde_json::json!({}))),
("POST", "/api/auth/bots/1/disconnect", None),
("DELETE", "/api/auth/bots/1", None),
("POST", "/api/auth/me/password", Some(serde_json::json!({}))),
("DELETE", "/api/auth/me/sessions", None),
("POST", "/api/users/1/promote", None),
];
for (method, uri, body) in cases {
let (status, resp) = send(real_api_app(&pool), method, uri, body, &token).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{method} {uri} must be closed to an OAuth token: {resp}"
);
}
}
#[tokio::test]
async fn oauth_token_still_works_on_ordinary_rest_routes() {
let pool = test_db();
let uid = seed_user(&pool, "reader");
let token = insert_oauth_token(&pool, "reads", Some(uid));
for uri in ["/api/projects", "/api/issues", "/api/users", "/api/auth/me"] {
let (status, body) = send(real_api_app(&pool), "GET", uri, None, &token).await;
assert_eq!(
status,
StatusCode::OK,
"GET {uri} must stay open to an OAuth token: {body}"
);
}
}
#[tokio::test]
async fn api_key_credential_may_no_longer_mint_a_key() {
let pool = test_db();
let uid = seed_user(&pool, "keyholder");
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "existing", Some(uid)).unwrap();
let (status, body) = send(real_api_app(&pool), "GET", "/api/auth/me", None, &key).await;
assert_eq!(
status,
StatusCode::OK,
"the key still authenticates: {body}"
);
let (status, body) = send(
real_api_app(&pool),
"POST",
"/api/auth/keys",
Some(serde_json::json!({ "name": "fresh" })),
&key,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
assert!(!body.contains("lific_sk"), "no key was returned: {body}");
}
#[tokio::test]
async fn session_credential_can_still_mint_a_key() {
let pool = test_db();
let uid = seed_user(&pool, "browseruser");
let session = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_session(&conn, uid, None)
.unwrap()
.token
};
let (status, body) = send(
real_api_app(&pool),
"POST",
"/api/auth/keys",
Some(serde_json::json!({ "name": "from-browser" })),
&session,
)
.await;
assert_eq!(status, StatusCode::OK, "session path unaffected: {body}");
assert!(body.contains("lific_sk"), "a key was returned: {body}");
}
#[tokio::test]
async fn mcp_endpoint_is_unaffected_by_the_oauth_deny_list() {
let pool = test_db();
let uid = seed_user(&pool, "mcpbot");
let token = insert_oauth_token(&pool, "mcp", Some(uid));
let app = Router::new()
.route("/mcp", axum::routing::post(|| async { "mcp-ok" }))
.layer(middleware::from_fn_with_state(
test_auth_state(&pool),
require_api_key,
));
let (status, body) = send(app, "POST", "/mcp", None, &token).await;
assert_eq!(status, StatusCode::OK, "/mcp must still accept the token");
assert_eq!(body, "mcp-ok");
}
#[tokio::test]
async fn oauth_transport_matches_between_actor_and_resolved_identity() {
let pool = test_db();
let uid = seed_user(&pool, "transportuser");
let token = insert_oauth_token(&pool, "transport", Some(uid));
async fn probe(
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
) -> String {
let actor = crate::actor::current();
format!(
"{}|{}",
actor.transport.as_str(),
identity.map_or_else(|| "none".into(), |i| i.transport.as_str().to_string())
)
}
for (uri, expected) in [("/mcp", "mcp|mcp"), ("/echo", "api|api")] {
let app = Router::new()
.route("/mcp", axum::routing::post(probe))
.route("/echo", axum::routing::post(probe))
.layer(middleware::from_fn_with_state(
test_auth_state(&pool),
require_api_key,
));
let (status, body) = send(app, "POST", uri, None, &token).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body, expected,
"{uri}: audit transport and resolved-identity transport must agree"
);
}
}
#[test]
fn oauth_blocked_reason_covers_the_credential_surface() {
for (method, path) in [
(Method::GET, "/api/auth/keys"),
(Method::POST, "/api/auth/keys"),
(Method::POST, "/api/auth/keys/"),
(Method::DELETE, "/api/auth/keys/42"),
(Method::GET, "/api/auth/bots"),
(Method::POST, "/api/auth/bots"),
(Method::POST, "/api/auth/bots/7/disconnect"),
(Method::DELETE, "/api/auth/bots/7"),
(Method::POST, "/api/auth/me/password"),
(Method::DELETE, "/api/auth/me/sessions"),
(Method::POST, "/api/auth/me/refresh"),
(Method::PATCH, "/api/auth/me"),
(Method::POST, "/api/users"),
(Method::POST, "/api/users/3/promote"),
(Method::POST, "/api/users/3/demote"),
(Method::POST, "/api/users/3/deactivate"),
(Method::POST, "/api/users/3/reactivate"),
(Method::POST, "/oauth/token"),
(Method::POST, "/oauth/register"),
] {
assert!(
oauth_blocked_reason(&method, path).is_some(),
"{method} {path} must be closed to OAuth tokens"
);
}
}
#[test]
fn oauth_blocked_reason_leaves_ordinary_routes_open() {
for (method, path) in [
(Method::GET, "/api/auth/me"),
(Method::GET, "/api/users"),
(Method::GET, "/api/projects"),
(Method::POST, "/api/projects"),
(Method::POST, "/api/issues"),
(Method::PUT, "/api/issues/5"),
(Method::GET, "/api/search"),
(Method::POST, "/mcp"),
(Method::GET, "/api/health"),
(Method::GET, "/api/auth/keysomething"),
(Method::GET, "/api/usersomething"),
] {
assert_eq!(
oauth_blocked_reason(&method, path),
None,
"{method} {path} must stay open to OAuth tokens"
);
}
}
}