use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use ironflow_auth::extractor::AuthenticatedUser;
use ironflow_auth::password;
use ironflow_store::entities::{ApiKeyScope, NewApiKey};
use rand::Rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::ApiError;
use crate::response::ok;
use crate::state::AppState;
use ironflow_auth::extractor::{API_KEY_PREFIX, API_KEY_SUFFIX_LEN};
const MAX_API_KEY_NAME_LEN: usize = 255;
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Deserialize)]
pub struct CreateApiKeyRequest {
pub name: String,
pub scopes: Vec<ApiKeyScope>,
pub expires_at: Option<DateTime<Utc>>,
pub rate_limit_override: Option<u32>,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Serialize)]
pub struct CreateApiKeyResponse {
pub id: Uuid,
pub key: String,
pub key_prefix: String,
pub name: String,
pub scopes: Vec<ApiKeyScope>,
pub expires_at: Option<DateTime<Utc>>,
pub rate_limit_override: Option<u32>,
pub created_at: DateTime<Utc>,
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
post,
path = "/api/v1/api-keys",
tags = ["api-keys"],
request_body(content = CreateApiKeyRequest, description = "API key configuration"),
responses(
(status = 201, description = "API key created successfully", body = CreateApiKeyResponse),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden (member assigning forbidden scopes or a rate_limit_override)")
),
security(("Bearer" = []))
)
)]
pub async fn create_api_key(
user: AuthenticatedUser,
State(state): State<AppState>,
Json(req): Json<CreateApiKeyRequest>,
) -> Result<impl IntoResponse, ApiError> {
let name = validate_name(&req.name)?.to_string();
if req.scopes.is_empty() {
return Err(ApiError::BadRequest(
"at least one scope is required".to_string(),
));
}
if !user.is_admin && !ApiKeyScope::all_allowed_for_member(&req.scopes) {
return Err(ApiError::Forbidden);
}
if !user.is_admin && req.rate_limit_override.is_some() {
return Err(ApiError::Forbidden);
}
if let Some(override_val) = req.rate_limit_override
&& override_val > 10_000
{
return Err(ApiError::BadRequest(
"rate_limit_override must be between 0 and 10000".to_string(),
));
}
let raw_key = generate_api_key();
let key_prefix = raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN].to_string();
let key_hash =
password::hash(&raw_key).map_err(|e| ApiError::Internal(format!("hashing: {e}")))?;
let api_key = state
.store
.create_api_key(NewApiKey {
user_id: user.user_id,
name,
key_hash,
key_prefix: key_prefix.clone(),
scopes: req.scopes,
expires_at: req.expires_at,
rate_limit_override: req.rate_limit_override,
})
.await
.map_err(ApiError::from)?;
let response = CreateApiKeyResponse {
id: api_key.id,
key: raw_key,
key_prefix,
name: api_key.name,
scopes: api_key.scopes,
expires_at: api_key.expires_at,
rate_limit_override: api_key.rate_limit_override,
created_at: api_key.created_at,
};
Ok((StatusCode::CREATED, ok(response)))
}
fn validate_name(name: &str) -> Result<&str, ApiError> {
let name = name.trim();
if name.is_empty() {
return Err(ApiError::BadRequest("name must not be empty".to_string()));
}
if name.chars().count() > MAX_API_KEY_NAME_LEN {
return Err(ApiError::BadRequest(format!(
"name must be at most {MAX_API_KEY_NAME_LEN} characters"
)));
}
Ok(name)
}
fn generate_api_key() -> String {
let mut rng = rand::rng();
let random_bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
let encoded = hex::encode(&random_bytes);
format!("{API_KEY_PREFIX}{encoded}")
}
#[cfg(test)]
mod tests {
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::routing::post;
use http_body_util::BodyExt;
use serde_json::{Value as JsonValue, from_slice};
use tower::ServiceExt;
use super::*;
use crate::routes::test_helpers::{create_user_auth_header, test_state};
use std::sync::Arc;
use ironflow_auth::jwt::JwtConfig;
use ironflow_core::providers::claude::ClaudeCodeProvider;
use ironflow_engine::engine::Engine;
use ironflow_engine::notify::Event;
use ironflow_store::entities::NewUser;
use ironflow_store::memory::InMemoryStore;
use ironflow_store::store::Store;
use tokio::sync::broadcast;
async fn admin_state() -> (AppState, AuthenticatedUser) {
let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
let provider = Arc::new(ClaudeCodeProvider::new());
let engine = Arc::new(Engine::new(store.clone(), provider));
let jwt_config = Arc::new(JwtConfig {
secret: "test-secret-for-api-keys".to_string(),
access_token_ttl_secs: 900,
refresh_token_ttl_secs: 604800,
cookie_domain: None,
cookie_secure: false,
});
let (event_sender, _) = broadcast::channel::<Event>(1);
let state = AppState::new(
store.clone(),
engine,
jwt_config,
"test-worker-token".to_string(),
event_sender,
);
let user = store
.create_user(NewUser {
email: "admin@example.com".to_string(),
username: "admin".to_string(),
password_hash: password::hash("password123").expect("hash"),
is_admin: Some(true),
})
.await
.expect("create user");
let auth = AuthenticatedUser {
user_id: user.id,
username: user.username,
is_admin: true,
};
(state, auth)
}
fn request(name: String) -> CreateApiKeyRequest {
CreateApiKeyRequest {
name,
scopes: vec![ApiKeyScope::RunsRead],
expires_at: None,
rate_limit_override: None,
}
}
#[test]
fn validate_name_rejects_empty_and_whitespace() {
assert!(matches!(validate_name(""), Err(ApiError::BadRequest(_))));
assert!(matches!(validate_name(" "), Err(ApiError::BadRequest(_))));
}
#[test]
fn validate_name_accepts_exactly_255_chars() {
let name = "a".repeat(255);
assert_eq!(validate_name(&name).expect("valid"), name);
}
#[test]
fn validate_name_rejects_256_chars() {
let name = "a".repeat(256);
match validate_name(&name) {
Err(ApiError::BadRequest(msg)) => assert!(msg.contains("255")),
_ => panic!("expected BadRequest"),
}
}
#[test]
fn validate_name_trims_before_counting() {
let padded = format!(" {} ", "a".repeat(255));
let name = validate_name(&padded).expect("valid");
assert_eq!(name.len(), 255);
}
#[test]
fn validate_name_counts_chars_not_bytes() {
let ok = "é".repeat(255);
assert!(validate_name(&ok).is_ok());
let too_long = "é".repeat(256);
assert!(matches!(
validate_name(&too_long),
Err(ApiError::BadRequest(_))
));
}
#[tokio::test]
async fn create_api_key_name_longer_than_255_returns_400() {
let (state, auth) = admin_state().await;
let user_id = auth.user_id;
let store = state.store.clone();
let result = create_api_key(auth, State(state), Json(request("a".repeat(300)))).await;
assert!(matches!(result, Err(ApiError::BadRequest(_))));
let keys = store
.list_api_keys_by_user(user_id)
.await
.expect("list keys");
assert!(keys.is_empty());
}
#[tokio::test]
async fn create_api_key_name_of_255_chars_returns_201() {
let (state, auth) = admin_state().await;
let response = create_api_key(auth, State(state), Json(request("a".repeat(255))))
.await
.expect("created")
.into_response();
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn create_api_key_stores_trimmed_name() {
let (state, auth) = admin_state().await;
let user_id = auth.user_id;
let store = state.store.clone();
let response = create_api_key(auth, State(state), Json(request(" key ".to_string())))
.await
.expect("created")
.into_response();
assert_eq!(response.status(), StatusCode::CREATED);
let keys = store
.list_api_keys_by_user(user_id)
.await
.expect("list keys");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].name, "key");
}
#[test]
fn generate_api_key_has_correct_prefix() {
let key = generate_api_key();
assert!(key.starts_with(API_KEY_PREFIX));
}
#[test]
fn generate_api_key_is_long_enough_for_prefix_extraction() {
let key = generate_api_key();
let expected_prefix_len = API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN;
assert!(
key.len() >= expected_prefix_len,
"key length {} is shorter than expected prefix length {}",
key.len(),
expected_prefix_len
);
let prefix = &key[..expected_prefix_len];
assert_eq!(prefix.len(), 13);
assert!(prefix.starts_with(API_KEY_PREFIX));
}
#[test]
fn generate_api_key_prefix_fits_varchar_16() {
let key = generate_api_key();
let prefix = &key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
assert!(
prefix.len() <= 16,
"prefix length {} exceeds VARCHAR(16)",
prefix.len()
);
}
#[test]
fn generate_api_key_produces_unique_keys() {
let key1 = generate_api_key();
let key2 = generate_api_key();
assert_ne!(key1, key2);
}
async fn post_create(is_admin: bool, body: &str) -> (StatusCode, JsonValue) {
let state = test_state();
let username = if is_admin { "admin" } else { "member" };
let auth = create_user_auth_header(&state, username, is_admin).await;
let app = Router::new()
.route("/", post(create_api_key))
.with_state(state);
let req = Request::builder()
.method("POST")
.uri("/")
.header("authorization", auth)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json = if bytes.is_empty() {
JsonValue::Null
} else {
from_slice(&bytes).unwrap()
};
(status, json)
}
#[tokio::test]
async fn member_setting_rate_limit_override_is_forbidden() {
let body = r#"{"name":"k","scopes":["runs_read"],"rate_limit_override":0}"#;
let (status, _) = post_create(false, body).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_can_set_rate_limit_override() {
let body = r#"{"name":"k","scopes":["runs_read"],"rate_limit_override":0}"#;
let (status, json) = post_create(true, body).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(json["data"]["rate_limit_override"], 0);
}
#[tokio::test]
async fn member_without_rate_limit_override_can_create_key() {
let body = r#"{"name":"k","scopes":["runs_read"]}"#;
let (status, json) = post_create(false, body).await;
assert_eq!(status, StatusCode::CREATED);
assert!(json["data"]["rate_limit_override"].is_null());
}
}