summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/api_keys.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/src/server/handlers/api_keys.rs
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/src/server/handlers/api_keys.rs')
-rw-r--r--makima/src/server/handlers/api_keys.rs282
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()
+ }
+ }
+}