summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/api_keys.rs
blob: 5a678a218189941fac07e34fb50ac95e1cb0b1a2 (plain) (tree)

























































































































































































































































































                                                                                                
//! 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()
        }
    }
}