use std::sync::Arc;
use axum::{
Router,
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
routing::post,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tokio::sync::broadcast;
use tracing::{debug, warn};
#[derive(Debug, Clone)]
pub struct OpenAIWebhookConfig {
pub webhook_secret: String,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct WebhookEvent {
pub event_type: String,
pub response_id: String,
pub response: Option<serde_json::Value>,
pub error: Option<String>,
}
#[derive(Clone)]
pub struct OpenAIWebhookHandler {
config: Arc<OpenAIWebhookConfig>,
tx: broadcast::Sender<WebhookEvent>,
}
impl OpenAIWebhookHandler {
pub fn new(config: OpenAIWebhookConfig) -> Self {
let (tx, _) = broadcast::channel(64);
Self { config: Arc::new(config), tx }
}
pub fn router(&self) -> Router {
let handler_state = self.clone();
Router::new().route(&self.config.path, post(handle_webhook).with_state(handler_state))
}
pub fn subscribe(&self) -> broadcast::Receiver<WebhookEvent> {
self.tx.subscribe()
}
pub fn validate_signature(&self, payload: &[u8], signature: &str) -> bool {
type HmacSha256 = Hmac<Sha256>;
let Ok(mut mac) = HmacSha256::new_from_slice(self.config.webhook_secret.as_bytes()) else {
warn!("invalid webhook secret length for HMAC");
return false;
};
mac.update(payload);
let Ok(signature_bytes) = hex::decode(signature) else {
debug!("webhook signature is not valid hex");
return false;
};
mac.verify_slice(&signature_bytes).is_ok()
}
}
const SIGNATURE_HEADER: &str = "x-openai-signature";
async fn handle_webhook(
State(handler): State<OpenAIWebhookHandler>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let signature = match headers.get(SIGNATURE_HEADER).and_then(|v| v.to_str().ok()) {
Some(sig) => sig,
None => {
warn!("webhook request missing signature header");
return StatusCode::UNAUTHORIZED;
}
};
if !handler.validate_signature(&body, signature) {
warn!("webhook signature validation failed");
return StatusCode::UNAUTHORIZED;
}
let payload: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
warn!("webhook payload parse error: {e}");
return StatusCode::BAD_REQUEST;
}
};
let event_type = match payload.get("type").and_then(|v| v.as_str()) {
Some(t) => t.to_string(),
None => {
warn!("webhook payload missing 'type' field");
return StatusCode::BAD_REQUEST;
}
};
let response_id = payload
.get("response")
.and_then(|r| r.get("id"))
.and_then(|v| v.as_str())
.or_else(|| payload.get("response_id").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
let response = payload.get("response").cloned();
let error = payload
.get("error")
.or_else(|| payload.get("response").and_then(|r| r.get("error")))
.map(|e| e.to_string());
let event = WebhookEvent { event_type, response_id, response, error };
debug!(
event_type = %event.event_type,
response_id = %event.response_id,
"webhook event received"
);
let _ = handler.tx.send(event);
StatusCode::OK
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_signature_valid() {
let config = OpenAIWebhookConfig {
webhook_secret: "test_secret".to_string(),
path: "/webhooks/openai".to_string(),
};
let handler = OpenAIWebhookHandler::new(config);
let payload = b"hello world";
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(b"test_secret").unwrap();
mac.update(payload);
let expected = hex::encode(mac.finalize().into_bytes());
assert!(handler.validate_signature(payload, &expected));
}
#[test]
fn test_validate_signature_invalid() {
let config = OpenAIWebhookConfig {
webhook_secret: "test_secret".to_string(),
path: "/webhooks/openai".to_string(),
};
let handler = OpenAIWebhookHandler::new(config);
let payload = b"hello world";
let bad_signature = "deadbeef0000000000000000000000000000000000000000000000000000000000";
assert!(!handler.validate_signature(payload, bad_signature));
}
#[test]
fn test_validate_signature_invalid_hex() {
let config = OpenAIWebhookConfig {
webhook_secret: "test_secret".to_string(),
path: "/webhooks/openai".to_string(),
};
let handler = OpenAIWebhookHandler::new(config);
assert!(!handler.validate_signature(b"payload", "not-valid-hex!@#$"));
}
#[test]
fn test_subscribe_returns_receiver() {
let config = OpenAIWebhookConfig {
webhook_secret: "secret".to_string(),
path: "/webhooks/openai".to_string(),
};
let handler = OpenAIWebhookHandler::new(config);
let _rx1 = handler.subscribe();
let _rx2 = handler.subscribe();
}
#[tokio::test]
async fn test_broadcast_event() {
let config = OpenAIWebhookConfig {
webhook_secret: "secret".to_string(),
path: "/webhooks/openai".to_string(),
};
let handler = OpenAIWebhookHandler::new(config);
let mut rx = handler.subscribe();
let event = WebhookEvent {
event_type: "response.completed".to_string(),
response_id: "resp_123".to_string(),
response: Some(serde_json::json!({"id": "resp_123", "status": "completed"})),
error: None,
};
handler.tx.send(event.clone()).unwrap();
let received = rx.recv().await.unwrap();
assert_eq!(received.event_type, "response.completed");
assert_eq!(received.response_id, "resp_123");
}
}