use crate::models::{now_utc_rfc3339, ApiToken}; use crate::state::AppState; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use rand::{distributions::Alphanumeric, Rng}; use sha2::{Digest, Sha256}; pub const TOKEN_PREFIX_LEN: usize = 12; /// Generate a new plaintext API token. It is shown to the user exactly once; /// only its SHA-256 hash is stored in the database. pub fn generate_api_token() -> String { let random: String = rand::thread_rng() .sample_iter(&Alphanumeric) .take(40) .map(char::from) .collect(); format!("sa_{}", random) } pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) } pub fn display_prefix(token: &str) -> String { token.chars().take(TOKEN_PREFIX_LEN).collect() } fn unauthorized() -> Response { ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "missing or invalid API token" })), ) .into_response() } /// Authenticate an API request via the `Authorization: Bearer ` header. /// Returns the token row on success (its name is used as the audit actor, /// e.g. `api:my-integration`) or a 401 JSON response. #[allow(clippy::result_large_err)] pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> Result { let header = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let token = header .strip_prefix("Bearer ") .map(str::trim) .filter(|t| !t.is_empty()); let token = match token { Some(t) => t, None => return Err(unauthorized()), }; let row: Option = sqlx::query_as( "SELECT * FROM api_tokens WHERE token_hash = ? AND enabled = true", ) .bind(hash_token(token)) .fetch_optional(&state.pool) .await .ok() .flatten(); let row = match row { Some(r) if r.is_enabled() => r, _ => return Err(unauthorized()), }; sqlx::query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?") .bind(now_utc_rfc3339()) .bind(row.id) .execute(&state.pool) .await .ok(); Ok(row) } pub fn api_actor(token: &ApiToken) -> String { format!("api:{}", token.name) }