use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use crate::db::Db;
use crate::error::AuthError;
use crate::sessions::generate_token;
use crate::types::{ApiTokenId, ApiTokenInfo, TokenHash, UserId};
fn hash_api_token(raw: &str) -> TokenHash {
let digest = Sha256::digest(raw.as_bytes());
TokenHash::new_unchecked(format!("{digest:x}"))
}
impl Db {
pub async fn create_api_token(
&self,
user_id: UserId,
name: &str,
expires_at: Option<DateTime<Utc>>,
metadata: Option<&str>,
) -> Result<(String, ApiTokenInfo), AuthError> {
let id = ApiTokenId::new();
let raw_session_token = generate_token();
let raw = raw_session_token.as_str().to_string();
let token_hash = hash_api_token(&raw);
let expires_str = expires_at.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string());
let info = sqlx::query_as::<_, ApiTokenInfo>(
"INSERT INTO allowthem_api_tokens (id, user_id, name, token_hash, expires_at, metadata)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, user_id, name, metadata, expires_at, created_at",
)
.bind(id)
.bind(user_id)
.bind(name)
.bind(token_hash)
.bind(expires_str)
.bind(metadata)
.fetch_one(self.pool())
.await
.map_err(AuthError::Database)?;
Ok((raw, info))
}
pub async fn validate_api_token(
&self,
raw_token: &str,
) -> Result<Option<(UserId, ApiTokenInfo)>, AuthError> {
let hash = hash_api_token(raw_token);
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let info = sqlx::query_as::<_, ApiTokenInfo>(
"SELECT id, user_id, name, metadata, expires_at, created_at
FROM allowthem_api_tokens
WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)",
)
.bind(hash)
.bind(now)
.fetch_optional(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(info.map(|i| (i.user_id, i)))
}
pub async fn list_api_tokens(&self, user_id: UserId) -> Result<Vec<ApiTokenInfo>, AuthError> {
sqlx::query_as::<_, ApiTokenInfo>(
"SELECT id, user_id, name, metadata, expires_at, created_at
FROM allowthem_api_tokens
WHERE user_id = ?
ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(self.pool())
.await
.map_err(AuthError::Database)
}
pub async fn delete_api_token(&self, id: ApiTokenId) -> Result<bool, AuthError> {
let result = sqlx::query("DELETE FROM allowthem_api_tokens WHERE id = ?")
.bind(id)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(result.rows_affected() > 0)
}
pub async fn delete_user_api_tokens(&self, user_id: UserId) -> Result<u64, AuthError> {
let result = sqlx::query("DELETE FROM allowthem_api_tokens WHERE user_id = ?")
.bind(user_id)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(result.rows_affected())
}
}
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use crate::db::Db;
use crate::types::{Email, UserId};
async fn test_db() -> Db {
Db::connect("sqlite::memory:")
.await
.expect("in-memory test db")
}
async fn create_test_user(db: &Db) -> UserId {
let email = Email::new(format!("user_{}@example.com", uuid::Uuid::now_v7())).unwrap();
let user = db
.create_user(email, "password123", None, None)
.await
.unwrap();
user.id
}
#[tokio::test]
async fn test_create_and_validate_api_token() {
let db = test_db().await;
let user_id = create_test_user(&db).await;
let (raw, info) = db
.create_api_token(user_id, "my-token", None, None)
.await
.unwrap();
assert_eq!(info.user_id, user_id);
assert_eq!(info.name, "my-token");
assert!(info.expires_at.is_none());
assert!(info.metadata.is_none());
let result = db.validate_api_token(&raw).await.unwrap();
let (uid, _token_info) = result.expect("token must be valid");
assert_eq!(uid, user_id);
}
#[tokio::test]
async fn test_expired_api_token_rejected() {
let db = test_db().await;
let user_id = create_test_user(&db).await;
let past = Utc::now() - Duration::hours(1);
let (raw, _) = db
.create_api_token(user_id, "expired-token", Some(past), None)
.await
.unwrap();
let result = db.validate_api_token(&raw).await.unwrap();
assert!(result.is_none(), "expired token must be rejected");
}
#[tokio::test]
async fn test_deleted_api_token_rejected() {
let db = test_db().await;
let user_id = create_test_user(&db).await;
let (raw, info) = db
.create_api_token(user_id, "delete-me", None, None)
.await
.unwrap();
let deleted = db.delete_api_token(info.id).await.unwrap();
assert!(deleted);
let result = db.validate_api_token(&raw).await.unwrap();
assert!(result.is_none(), "deleted token must be rejected");
}
#[tokio::test]
async fn test_list_api_tokens() {
let db = test_db().await;
let user_id = create_test_user(&db).await;
db.create_api_token(user_id, "token-a", None, None)
.await
.unwrap();
db.create_api_token(user_id, "token-b", None, None)
.await
.unwrap();
let tokens = db.list_api_tokens(user_id).await.unwrap();
assert_eq!(tokens.len(), 2);
let names: Vec<&str> = tokens.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"token-a"));
assert!(names.contains(&"token-b"));
}
#[tokio::test]
async fn test_cascade_delete_removes_api_tokens() {
let db = test_db().await;
let user_id = create_test_user(&db).await;
db.create_api_token(user_id, "to-be-cascaded", None, None)
.await
.unwrap();
db.delete_user(user_id).await.unwrap();
let token_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_api_tokens WHERE user_id = ?")
.bind(user_id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(token_count, 0, "api tokens must cascade-delete with user");
}
#[tokio::test]
async fn test_create_with_metadata() {
let db = test_db().await;
let user_id = create_test_user(&db).await;
let (raw, info) = db
.create_api_token(user_id, "meta-token", None, Some("key=value"))
.await
.unwrap();
assert_eq!(info.metadata.as_deref(), Some("key=value"));
let (uid, token_info) = db
.validate_api_token(&raw)
.await
.unwrap()
.expect("token must be valid");
assert_eq!(uid, user_id);
assert_eq!(token_info.metadata.as_deref(), Some("key=value"));
let tokens = db.list_api_tokens(user_id).await.unwrap();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].metadata.as_deref(), Some("key=value"));
}
}