use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::{NaiveDateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{query, FromRow};
use tracing::{info, warn};
use uuid::Uuid;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
use crate::errors::{LicenseError, LicenseResult};
use crate::server::database::Database;
use crate::server::handlers::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ApiToken {
pub id: String,
pub name: String,
#[serde(skip_serializing)]
pub token_hash: String,
pub scopes: String,
pub created_at: NaiveDateTime,
pub expires_at: Option<NaiveDateTime>,
pub last_used_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
pub created_by: Option<String>,
}
impl ApiToken {
pub fn has_scope(&self, required: &str) -> bool {
if self.scopes.split_whitespace().any(|s| s == "*") {
return true;
}
for scope in self.scopes.split_whitespace() {
if scope == required {
return true;
}
if let Some(prefix) = scope.strip_suffix(":*") {
if required.starts_with(prefix) && required.chars().nth(prefix.len()) == Some(':') {
return true;
}
}
}
false
}
pub fn is_valid(&self) -> bool {
if self.revoked_at.is_some() {
return false;
}
if let Some(expires_at) = self.expires_at {
if Utc::now().naive_utc() > expires_at {
return false;
}
}
true
}
pub fn scope_list(&self) -> Vec<String> {
self.scopes.split_whitespace().map(String::from).collect()
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CreateTokenResponse {
pub token: TokenMetadata,
pub raw_token: String,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TokenMetadata {
pub id: String,
pub name: String,
pub scopes: Vec<String>,
pub created_at: String,
pub expires_at: Option<String>,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
pub created_by: Option<String>,
pub is_active: bool,
}
impl From<ApiToken> for TokenMetadata {
fn from(token: ApiToken) -> Self {
let is_active = token.is_valid();
let scopes = token.scope_list();
TokenMetadata {
id: token.id,
name: token.name,
scopes,
created_at: token.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
expires_at: token
.expires_at
.map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
last_used_at: token
.last_used_at
.map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
revoked_at: token
.revoked_at
.map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
created_by: token.created_by,
is_active,
}
}
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CreateTokenRequest {
pub name: String,
pub scopes: Vec<String>,
pub expires_at: Option<String>,
}
fn generate_raw_token() -> String {
format!("talos_{}", Uuid::new_v4().to_string().replace('-', ""))
}
fn hash_token(raw_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(raw_token.as_bytes());
format!("{:x}", hasher.finalize())
}
impl Database {
pub async fn create_api_token(
&self,
name: &str,
scopes: &[&str],
expires_at: Option<NaiveDateTime>,
created_by: Option<&str>,
) -> LicenseResult<(ApiToken, String)> {
let id = Uuid::new_v4().to_string();
let raw_token = generate_raw_token();
let token_hash = hash_token(&raw_token);
let now = Utc::now().naive_utc();
let scopes_str = scopes.join(" ");
let token = ApiToken {
id: id.clone(),
name: name.to_string(),
token_hash: token_hash.clone(),
scopes: scopes_str.clone(),
created_at: now,
expires_at,
last_used_at: None,
revoked_at: None,
created_by: created_by.map(String::from),
};
match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => {
query(
"INSERT INTO api_tokens (id, name, token_hash, scopes, created_at, expires_at, created_by) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(name)
.bind(&token_hash)
.bind(&scopes_str)
.bind(now)
.bind(expires_at)
.bind(created_by)
.execute(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("failed to create token: {e}")))?;
}
#[cfg(feature = "postgres")]
Database::Postgres(pool) => {
query(
"INSERT INTO api_tokens (id, name, token_hash, scopes, created_at, expires_at, created_by) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(&id)
.bind(name)
.bind(&token_hash)
.bind(&scopes_str)
.bind(now)
.bind(expires_at)
.bind(created_by)
.execute(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("failed to create token: {e}")))?;
}
}
info!("Created API token '{}' with id={}", name, id);
Ok((token, raw_token))
}
pub async fn validate_api_token(&self, raw_token: &str) -> LicenseResult<Option<ApiToken>> {
let token_hash = hash_token(raw_token);
let token: Option<ApiToken> = match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => sqlx::query_as::<_, ApiToken>(
"SELECT id, name, token_hash, scopes, created_at, expires_at, \
last_used_at, revoked_at, created_by \
FROM api_tokens WHERE token_hash = ?",
)
.bind(&token_hash)
.fetch_optional(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("token lookup failed: {e}")))?,
#[cfg(feature = "postgres")]
Database::Postgres(pool) => sqlx::query_as::<_, ApiToken>(
"SELECT id, name, token_hash, scopes, created_at, expires_at, \
last_used_at, revoked_at, created_by \
FROM api_tokens WHERE token_hash = $1",
)
.bind(&token_hash)
.fetch_optional(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("token lookup failed: {e}")))?,
};
if let Some(ref t) = token {
if t.is_valid() {
self.update_token_last_used(&t.id).await?;
}
}
Ok(token)
}
async fn update_token_last_used(&self, token_id: &str) -> LicenseResult<()> {
let now = Utc::now().naive_utc();
match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => {
query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
.bind(now)
.bind(token_id)
.execute(pool)
.await
.map_err(|e| {
LicenseError::ServerError(format!("update last_used failed: {e}"))
})?;
}
#[cfg(feature = "postgres")]
Database::Postgres(pool) => {
query("UPDATE api_tokens SET last_used_at = $1 WHERE id = $2")
.bind(now)
.bind(token_id)
.execute(pool)
.await
.map_err(|e| {
LicenseError::ServerError(format!("update last_used failed: {e}"))
})?;
}
}
Ok(())
}
pub async fn list_api_tokens(&self) -> LicenseResult<Vec<ApiToken>> {
match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => sqlx::query_as::<_, ApiToken>(
"SELECT id, name, token_hash, scopes, created_at, expires_at, \
last_used_at, revoked_at, created_by \
FROM api_tokens ORDER BY created_at DESC",
)
.fetch_all(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("list tokens failed: {e}"))),
#[cfg(feature = "postgres")]
Database::Postgres(pool) => sqlx::query_as::<_, ApiToken>(
"SELECT id, name, token_hash, scopes, created_at, expires_at, \
last_used_at, revoked_at, created_by \
FROM api_tokens ORDER BY created_at DESC",
)
.fetch_all(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("list tokens failed: {e}"))),
}
}
pub async fn get_api_token(&self, token_id: &str) -> LicenseResult<Option<ApiToken>> {
match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => sqlx::query_as::<_, ApiToken>(
"SELECT id, name, token_hash, scopes, created_at, expires_at, \
last_used_at, revoked_at, created_by \
FROM api_tokens WHERE id = ?",
)
.bind(token_id)
.fetch_optional(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("get token failed: {e}"))),
#[cfg(feature = "postgres")]
Database::Postgres(pool) => sqlx::query_as::<_, ApiToken>(
"SELECT id, name, token_hash, scopes, created_at, expires_at, \
last_used_at, revoked_at, created_by \
FROM api_tokens WHERE id = $1",
)
.bind(token_id)
.fetch_optional(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("get token failed: {e}"))),
}
}
pub async fn revoke_api_token(&self, token_id: &str) -> LicenseResult<bool> {
let now = Utc::now().naive_utc();
let rows_affected = match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => {
query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
.bind(now)
.bind(token_id)
.execute(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("revoke token failed: {e}")))?
.rows_affected()
}
#[cfg(feature = "postgres")]
Database::Postgres(pool) => {
query("UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND revoked_at IS NULL")
.bind(now)
.bind(token_id)
.execute(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("revoke token failed: {e}")))?
.rows_affected()
}
};
if rows_affected > 0 {
warn!("Revoked API token id={}", token_id);
}
Ok(rows_affected > 0)
}
pub async fn has_api_tokens(&self) -> LicenseResult<bool> {
match self {
#[cfg(feature = "sqlite")]
Database::SQLite(pool) => {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM api_tokens")
.fetch_one(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("count tokens failed: {e}")))?;
Ok(count.0 > 0)
}
#[cfg(feature = "postgres")]
Database::Postgres(pool) => {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM api_tokens")
.fetch_one(pool)
.await
.map_err(|e| LicenseError::ServerError(format!("count tokens failed: {e}")))?;
Ok(count.0 > 0)
}
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ListTokensResponse {
pub tokens: Vec<TokenMetadata>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TokenResponse {
pub token: TokenMetadata,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct RevokeTokenResponse {
pub success: bool,
pub message: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TokenErrorResponse {
pub error: String,
pub code: String,
}
impl TokenErrorResponse {
fn new(error: impl Into<String>, code: impl Into<String>) -> Self {
Self {
error: error.into(),
code: code.into(),
}
}
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/tokens",
tag = "tokens",
request_body = CreateTokenRequest,
responses(
(status = 201, description = "Token created", body = CreateTokenResponse),
(status = 400, description = "Invalid request", body = TokenErrorResponse),
(status = 500, description = "Server error", body = TokenErrorResponse),
),
security(("bearer_auth" = []))
))]
pub async fn create_token_handler(
State(state): State<AppState>,
Json(req): Json<CreateTokenRequest>,
) -> impl IntoResponse {
if req.name.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!(TokenErrorResponse::new(
"Token name is required",
"INVALID_NAME"
))),
)
.into_response();
}
if req.scopes.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!(TokenErrorResponse::new(
"At least one scope is required",
"INVALID_SCOPES"
))),
)
.into_response();
}
let expires_at = match &req.expires_at {
Some(exp_str) => match NaiveDateTime::parse_from_str(exp_str, "%Y-%m-%dT%H:%M:%SZ") {
Ok(dt) => Some(dt),
Err(_) => match NaiveDateTime::parse_from_str(exp_str, "%Y-%m-%dT%H:%M:%S") {
Ok(dt) => Some(dt),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!(TokenErrorResponse::new(
"Invalid expires_at format. Use ISO 8601 format.",
"INVALID_EXPIRATION"
))),
)
.into_response();
}
},
},
None => None,
};
let scope_refs: Vec<&str> = req.scopes.iter().map(|s| s.as_str()).collect();
match state
.db
.create_api_token(&req.name, &scope_refs, expires_at, None)
.await
{
Ok((token, raw_token)) => {
let response = CreateTokenResponse {
token: TokenMetadata::from(token),
raw_token,
};
(StatusCode::CREATED, Json(serde_json::json!(response))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!(TokenErrorResponse::new(
format!("Failed to create token: {}", e),
"CREATE_FAILED"
))),
)
.into_response(),
}
}
#[cfg_attr(feature = "openapi", utoipa::path(
get,
path = "/api/v1/tokens",
tag = "tokens",
responses(
(status = 200, description = "List of tokens", body = ListTokensResponse),
(status = 500, description = "Server error", body = TokenErrorResponse),
),
security(("bearer_auth" = []))
))]
pub async fn list_tokens_handler(State(state): State<AppState>) -> impl IntoResponse {
match state.db.list_api_tokens().await {
Ok(tokens) => {
let metadata: Vec<TokenMetadata> =
tokens.into_iter().map(TokenMetadata::from).collect();
let response = ListTokensResponse { tokens: metadata };
(StatusCode::OK, Json(serde_json::json!(response))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!(TokenErrorResponse::new(
format!("Failed to list tokens: {}", e),
"LIST_FAILED"
))),
)
.into_response(),
}
}
#[cfg_attr(feature = "openapi", utoipa::path(
get,
path = "/api/v1/tokens/{id}",
tag = "tokens",
params(
("id" = String, Path, description = "Token ID")
),
responses(
(status = 200, description = "Token details", body = TokenResponse),
(status = 404, description = "Token not found", body = TokenErrorResponse),
(status = 500, description = "Server error", body = TokenErrorResponse),
),
security(("bearer_auth" = []))
))]
pub async fn get_token_handler(
State(state): State<AppState>,
Path(token_id): Path<String>,
) -> impl IntoResponse {
match state.db.get_api_token(&token_id).await {
Ok(Some(token)) => {
let response = TokenResponse {
token: TokenMetadata::from(token),
};
(StatusCode::OK, Json(serde_json::json!(response))).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!(TokenErrorResponse::new(
"Token not found",
"NOT_FOUND"
))),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!(TokenErrorResponse::new(
format!("Failed to get token: {}", e),
"GET_FAILED"
))),
)
.into_response(),
}
}
#[cfg_attr(feature = "openapi", utoipa::path(
delete,
path = "/api/v1/tokens/{id}",
tag = "tokens",
params(
("id" = String, Path, description = "Token ID")
),
responses(
(status = 200, description = "Token revoked", body = RevokeTokenResponse),
(status = 404, description = "Token not found", body = TokenErrorResponse),
(status = 500, description = "Server error", body = TokenErrorResponse),
),
security(("bearer_auth" = []))
))]
pub async fn revoke_token_handler(
State(state): State<AppState>,
Path(token_id): Path<String>,
) -> impl IntoResponse {
match state.db.revoke_api_token(&token_id).await {
Ok(true) => {
let response = RevokeTokenResponse {
success: true,
message: "Token revoked successfully".to_string(),
};
(StatusCode::OK, Json(serde_json::json!(response))).into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!(TokenErrorResponse::new(
"Token not found or already revoked",
"NOT_FOUND"
))),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!(TokenErrorResponse::new(
format!("Failed to revoke token: {}", e),
"REVOKE_FAILED"
))),
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_has_scope_exact_match() {
let token = ApiToken {
id: "test".to_string(),
name: "Test".to_string(),
token_hash: "hash".to_string(),
scopes: "licenses:read licenses:write".to_string(),
created_at: Utc::now().naive_utc(),
expires_at: None,
last_used_at: None,
revoked_at: None,
created_by: None,
};
assert!(token.has_scope("licenses:read"));
assert!(token.has_scope("licenses:write"));
assert!(!token.has_scope("licenses:delete"));
assert!(!token.has_scope("admin:read"));
}
#[test]
fn token_has_scope_wildcard() {
let token = ApiToken {
id: "test".to_string(),
name: "Test".to_string(),
token_hash: "hash".to_string(),
scopes: "*".to_string(),
created_at: Utc::now().naive_utc(),
expires_at: None,
last_used_at: None,
revoked_at: None,
created_by: None,
};
assert!(token.has_scope("licenses:read"));
assert!(token.has_scope("anything:here"));
}
#[test]
fn token_has_scope_category_wildcard() {
let token = ApiToken {
id: "test".to_string(),
name: "Test".to_string(),
token_hash: "hash".to_string(),
scopes: "licenses:*".to_string(),
created_at: Utc::now().naive_utc(),
expires_at: None,
last_used_at: None,
revoked_at: None,
created_by: None,
};
assert!(token.has_scope("licenses:read"));
assert!(token.has_scope("licenses:write"));
assert!(token.has_scope("licenses:delete"));
assert!(!token.has_scope("admin:read"));
}
#[test]
fn token_is_valid_active() {
let token = ApiToken {
id: "test".to_string(),
name: "Test".to_string(),
token_hash: "hash".to_string(),
scopes: "*".to_string(),
created_at: Utc::now().naive_utc(),
expires_at: None,
last_used_at: None,
revoked_at: None,
created_by: None,
};
assert!(token.is_valid());
}
#[test]
fn token_is_valid_revoked() {
let token = ApiToken {
id: "test".to_string(),
name: "Test".to_string(),
token_hash: "hash".to_string(),
scopes: "*".to_string(),
created_at: Utc::now().naive_utc(),
expires_at: None,
last_used_at: None,
revoked_at: Some(Utc::now().naive_utc()),
created_by: None,
};
assert!(!token.is_valid());
}
#[test]
fn token_is_valid_expired() {
let token = ApiToken {
id: "test".to_string(),
name: "Test".to_string(),
token_hash: "hash".to_string(),
scopes: "*".to_string(),
created_at: Utc::now().naive_utc(),
expires_at: Some(Utc::now().naive_utc() - chrono::Duration::hours(1)),
last_used_at: None,
revoked_at: None,
created_by: None,
};
assert!(!token.is_valid());
}
#[test]
fn hash_token_produces_sha256() {
let raw = "talos_abc123";
let hash = hash_token(raw);
assert_eq!(hash.len(), 64);
assert_eq!(hash, hash_token(raw));
assert_ne!(hash, hash_token("talos_xyz789"));
}
#[test]
fn generate_raw_token_format() {
let token = generate_raw_token();
assert!(token.starts_with("talos_"));
assert_eq!(token.len(), 38);
}
}