//! HTTP handlers for API key management.
//!
//! These endpoints allow users to create, view, refresh, and revoke their API keys.
//! API keys are used for daemon authentication and programmatic access.
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
Json,
};
use crate::server::auth::{
create_api_key, generate_api_key, get_active_api_key, refresh_api_key, revoke_api_key,
ApiKeyError, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, UserOnly,
};
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
/// Create a new API key for the authenticated user.
///
/// Each user can only have one active API key at a time. If an existing key
/// exists, this will return a 409 Conflict error - use the refresh endpoint
/// to replace the existing key, or revoke it first.
#[utoipa::path(
post,
path = "/api/v1/auth/api-keys",
request_body = CreateApiKeyRequest,
responses(
(status = 201, description = "API key created", body = CreateApiKeyResponse),
(status = 401, description = "Not authenticated", body = ApiError),
(status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
(status = 409, description = "API key already exists", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = [])
),
tag = "API Keys"
)]
pub async fn create_api_key_handler(
State(state): State<SharedState>,
UserOnly(user): UserOnly,
Json(req): Json<CreateApiKeyRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Generate a new API key
let generated = generate_api_key();
match create_api_key(pool, user.user_id, &generated, req.name.as_deref()).await {
Ok(key) => {
let response = CreateApiKeyResponse {
id: key.id,
key: generated.full_key,
prefix: key.key_prefix,
name: key.name,
created_at: key.created_at,
};
(StatusCode::CREATED, Json(response)).into_response()
}
Err(ApiKeyError::KeyAlreadyExists) => (
StatusCode::CONFLICT,
Json(ApiError::new(
"KEY_EXISTS",
"An active API key already exists. Revoke it first or use refresh.",
)),
)
.into_response(),
Err(ApiKeyError::Database(e)) => {
tracing::error!("Failed to create API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to create API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("INTERNAL_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Get information about the current active API key.
///
/// Returns the key's ID, prefix (for identification), name, and timestamps.
/// The full key is never returned - it was only shown once when created.
#[utoipa::path(
get,
path = "/api/v1/auth/api-keys",
responses(
(status = 200, description = "API key info", body = ApiKeyInfoResponse),
(status = 401, description = "Not authenticated", body = ApiError),
(status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
(status = 404, description = "No active API key", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = [])
),
tag = "API Keys"
)]
pub async fn get_api_key_handler(
State(state): State<SharedState>,
UserOnly(user): UserOnly,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match get_active_api_key(pool, user.user_id).await {
Ok(Some(key)) => {
let response: ApiKeyInfoResponse = key.into();
Json(response).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NO_KEY", "No active API key found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Refresh the current API key.
///
/// This revokes the existing key (if any) and creates a new one atomically.
/// Use this for key rotation without downtime.
#[utoipa::path(
post,
path = "/api/v1/auth/api-keys/refresh",
request_body = RefreshApiKeyRequest,
responses(
(status = 200, description = "API key refreshed", body = RefreshApiKeyResponse),
(status = 401, description = "Not authenticated", body = ApiError),
(status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = [])
),
tag = "API Keys"
)]
pub async fn refresh_api_key_handler(
State(state): State<SharedState>,
UserOnly(user): UserOnly,
Json(req): Json<RefreshApiKeyRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Generate a new API key
let generated = generate_api_key();
match refresh_api_key(pool, user.user_id, &generated, req.name.as_deref()).await {
Ok((key, old_prefix)) => {
// Invalidate cache for the old key if we had a cache
// (The cache lookup is by hash, but we revoked the old key in DB so it won't match)
let response = RefreshApiKeyResponse {
id: key.id,
key: generated.full_key,
prefix: key.key_prefix,
name: key.name,
created_at: key.created_at,
previous_key_revoked: old_prefix.is_some(),
};
Json(response).into_response()
}
Err(ApiKeyError::Database(e)) => {
tracing::error!("Failed to refresh API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to refresh API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("INTERNAL_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Revoke the current active API key.
///
/// After revocation, the key can no longer be used for authentication.
/// A new key can be created after revocation.
#[utoipa::path(
delete,
path = "/api/v1/auth/api-keys",
responses(
(status = 200, description = "API key revoked", body = RevokeApiKeyResponse),
(status = 401, description = "Not authenticated", body = ApiError),
(status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
(status = 404, description = "No active API key", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = [])
),
tag = "API Keys"
)]
pub async fn revoke_api_key_handler(
State(state): State<SharedState>,
UserOnly(user): UserOnly,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match revoke_api_key(pool, user.user_id).await {
Ok(key) => {
let response = RevokeApiKeyResponse {
message: "API key revoked successfully".to_string(),
revoked_key_prefix: key.key_prefix,
};
Json(response).into_response()
}
Err(ApiKeyError::KeyNotFound) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NO_KEY", "No active API key found")),
)
.into_response(),
Err(ApiKeyError::Database(e)) => {
tracing::error!("Failed to revoke API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to revoke API key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("INTERNAL_ERROR", e.to_string())),
)
.into_response()
}
}
}