diff options
Diffstat (limited to 'makima/src/server/handlers/api_keys.rs')
| -rw-r--r-- | makima/src/server/handlers/api_keys.rs | 282 |
1 files changed, 282 insertions, 0 deletions
diff --git a/makima/src/server/handlers/api_keys.rs b/makima/src/server/handlers/api_keys.rs new file mode 100644 index 0000000..5a678a2 --- /dev/null +++ b/makima/src/server/handlers/api_keys.rs @@ -0,0 +1,282 @@ +//! 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() + } + } +} |
