use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use rusqlite::params;
use tracing::{info, warn};
use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret, KeyStatus, SecureString};
use crate::db::DbPool;
#[derive(Clone)]
pub struct AuthState {
pub db: DbPool,
pub manager: ApiKeyManagerV0,
pub public_url: String,
}
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,
) -> Result<String, crate::error::LificError> {
let conn = db.write()?;
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM api_keys WHERE name = ?1 AND revoked = 0",
params![name],
|row| row.get(0),
)
.unwrap_or(false);
if exists {
return Err(crate::error::LificError::BadRequest(format!(
"an active key named '{name}' already exists"
)));
}
let api_key = manager
.generate(Environment::production())
.map_err(|e| crate::error::LificError::Internal(format!("key generation failed: {e}")))?;
let plaintext = api_key.key().expose_secret().to_string();
let hash = api_key.expose_hash().hash().to_string();
let key_id = api_key.expose_hash().key_id().to_string();
conn.execute(
"INSERT INTO api_keys (name, key_hash, key_id) VALUES (?1, ?2, ?3)",
params![name, hash, key_id],
)?;
Ok(plaintext)
}
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],
)?;
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> {
let conn = db.write()?;
let changed = conn.execute("DELETE FROM api_keys WHERE name = ?1", params![name])?;
if changed == 0 {
return Err(crate::error::LificError::NotFound(format!(
"no key named '{name}'"
)));
}
drop(conn);
create_api_key(db, manager, name)
}
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
}
}
#[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,
}
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 www_auth = format!(
"Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource\"",
auth.public_url
);
let Some(token) = token else {
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.write() {
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,
};
request.extensions_mut().insert(Some(auth_user));
return 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_") {
if crate::oauth::validate_oauth_token(&auth.db, &token) {
request
.extensions_mut()
.insert(None::<crate::db::models::AuthUser>);
return next.run(request).await;
}
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid or expired OAuth token",
)
.into_response();
}
let secure_token = SecureString::from(token);
match auth.manager.verify_checksum(&secure_token) {
Ok(true) => {} _ => {
warn!("rejected API key with invalid checksum");
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid API key",
)
.into_response();
}
}
let key_id = auth.manager.extract_key_id(&secure_token);
let key_row: Option<ApiKeyRow> = {
let conn = match auth.db.read() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
conn.query_row(
"SELECT id, key_hash, user_id FROM api_keys WHERE key_id = ?1 AND revoked = 0",
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 = auth.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",
)
.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) = auth.manager.verify(&secure_token, &row.hash) {
if let Ok(wconn) = auth.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 {
warn!("rejected invalid API key");
return (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid API key",
)
.into_response();
};
match auth.manager.verify(&secure_token, &key.hash) {
Ok(KeyStatus::Valid) => {
let auth_user = key.user_id.and_then(|uid| {
let conn = auth.db.read().ok()?;
crate::db::queries::users::get_user_by_id(&conn, uid)
.ok()
.map(|u| crate::db::models::AuthUser {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
})
});
request.extensions_mut().insert(auth_user);
next.run(request).await
}
_ => {
warn!("API key hash verification failed");
(
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", www_auth.as_str())],
"Invalid API key",
)
.into_response()
}
}
}
#[derive(Debug)]
struct ApiKeyRow {
#[allow(dead_code)]
id: i64,
hash: String,
user_id: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use api_keys_simplified::SecureString;
fn test_db() -> db::DbPool {
db::open_memory().expect("test db")
}
#[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").unwrap();
assert!(key.starts_with("lific_sk-live-"));
}
#[test]
fn verify_key_succeeds() {
let pool = test_db();
let manager = create_key_manager().unwrap();
let key = create_api_key(&pool, &manager, "test-key").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").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);
match status {
Ok(KeyStatus::Valid) => 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").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").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 duplicate_name_rejected() {
let pool = test_db();
let manager = create_key_manager().unwrap();
create_api_key(&pool, &manager, "unique").unwrap();
let result = create_api_key(&pool, &manager, "unique");
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").unwrap();
assert!(has_any_keys(&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").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").unwrap();
let _key2 = create_api_key(&pool, &manager, "key-2").unwrap();
let secure_key = SecureString::from(key1.clone());
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").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));
}
}