55//! the integration point for scripts, CI pipelines, and other programs
66//! that need to interact with Spacebot programmatically.
77
8- use crate :: messaging :: traits :: { InboundStream , Messaging } ;
9- use crate :: { InboundMessage , MessageContent , OutboundResponse } ;
8+ use std :: collections :: HashMap ;
9+ use std :: sync :: Arc ;
1010
1111use anyhow:: Context as _;
1212use axum:: Router ;
1313use axum:: extract:: { Json , State } ;
14- use axum:: http:: StatusCode ;
14+ use axum:: http:: header:: AUTHORIZATION ;
15+ use axum:: http:: { HeaderMap , StatusCode } ;
1516use axum:: routing:: { get, post} ;
1617use serde:: { Deserialize , Serialize } ;
17-
18- use std:: collections:: HashMap ;
19- use std:: sync:: Arc ;
2018use tokio:: sync:: { RwLock , mpsc} ;
2119
20+ use crate :: messaging:: traits:: { InboundStream , Messaging } ;
21+ use crate :: { InboundMessage , MessageContent , OutboundResponse } ;
22+
2223/// Webhook adapter state.
2324pub struct WebhookAdapter {
2425 port : u16 ,
2526 bind : String ,
27+ auth_token : Option < String > ,
2628 inbound_tx : Arc < RwLock < Option < mpsc:: Sender < InboundMessage > > > > ,
2729 /// Buffered responses per conversation_id, waiting to be polled.
2830 response_buffers : Arc < RwLock < HashMap < String , Vec < WebhookResponse > > > > ,
@@ -34,6 +36,7 @@ pub struct WebhookAdapter {
3436struct AppState {
3537 inbound_tx : Arc < RwLock < Option < mpsc:: Sender < InboundMessage > > > > ,
3638 response_buffers : Arc < RwLock < HashMap < String , Vec < WebhookResponse > > > > ,
39+ auth_token : Option < String > ,
3740}
3841
3942/// Inbound webhook request body.
@@ -72,10 +75,11 @@ struct PollResponse {
7275}
7376
7477impl WebhookAdapter {
75- pub fn new ( port : u16 , bind : impl Into < String > ) -> Self {
78+ pub fn new ( port : u16 , bind : impl Into < String > , auth_token : Option < String > ) -> Self {
7679 Self {
7780 port,
7881 bind : bind. into ( ) ,
82+ auth_token,
7983 inbound_tx : Arc :: new ( RwLock :: new ( None ) ) ,
8084 response_buffers : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
8185 shutdown_tx : Arc :: new ( RwLock :: new ( None ) ) ,
@@ -98,8 +102,15 @@ impl Messaging for WebhookAdapter {
98102 let state = AppState {
99103 inbound_tx : self . inbound_tx . clone ( ) ,
100104 response_buffers : self . response_buffers . clone ( ) ,
105+ auth_token : self . auth_token . clone ( ) ,
101106 } ;
102107
108+ if self . auth_token . is_none ( ) {
109+ tracing:: warn!(
110+ "webhook authentication is disabled because no auth token is configured"
111+ ) ;
112+ }
113+
103114 let app = Router :: new ( )
104115 . route ( "/send" , post ( handle_send) )
105116 . route ( "/poll/{conversation_id}" , get ( handle_poll) )
@@ -226,9 +237,14 @@ impl Messaging for WebhookAdapter {
226237// -- Axum handlers --
227238
228239async fn handle_send (
240+ headers : HeaderMap ,
229241 State ( state) : State < AppState > ,
230242 Json ( request) : Json < WebhookRequest > ,
231243) -> Result < StatusCode , ( StatusCode , String ) > {
244+ if !is_authorized ( & headers, state. auth_token . as_deref ( ) ) {
245+ return Err ( ( StatusCode :: UNAUTHORIZED , "unauthorized" . into ( ) ) ) ;
246+ }
247+
232248 let tx = state. inbound_tx . read ( ) . await ;
233249 let Some ( tx) = tx. as_ref ( ) else {
234250 return Err ( (
@@ -269,9 +285,14 @@ async fn handle_send(
269285}
270286
271287async fn handle_poll (
288+ headers : HeaderMap ,
272289 State ( state) : State < AppState > ,
273290 axum:: extract:: Path ( conversation_id) : axum:: extract:: Path < String > ,
274- ) -> Json < PollResponse > {
291+ ) -> Result < Json < PollResponse > , ( StatusCode , String ) > {
292+ if !is_authorized ( & headers, state. auth_token . as_deref ( ) ) {
293+ return Err ( ( StatusCode :: UNAUTHORIZED , "unauthorized" . into ( ) ) ) ;
294+ }
295+
275296 let key = format ! ( "webhook:{conversation_id}" ) ;
276297 let messages = state
277298 . response_buffers
@@ -280,9 +301,29 @@ async fn handle_poll(
280301 . remove ( & key)
281302 . unwrap_or_default ( ) ;
282303
283- Json ( PollResponse { messages } )
304+ Ok ( Json ( PollResponse { messages } ) )
284305}
285306
286307async fn handle_health ( ) -> StatusCode {
287308 StatusCode :: OK
288309}
310+
311+ fn is_authorized ( headers : & HeaderMap , expected_token : Option < & str > ) -> bool {
312+ let Some ( expected_token) = expected_token else {
313+ return true ;
314+ } ;
315+
316+ if headers
317+ . get ( "x-webhook-token" )
318+ . and_then ( |value| value. to_str ( ) . ok ( ) )
319+ . is_some_and ( |token| token == expected_token)
320+ {
321+ return true ;
322+ }
323+
324+ headers
325+ . get ( AUTHORIZATION )
326+ . and_then ( |value| value. to_str ( ) . ok ( ) )
327+ . and_then ( |value| value. strip_prefix ( "Bearer " ) )
328+ . is_some_and ( |token| token == expected_token)
329+ }
0 commit comments