From 020f193ac84f70af977b8482e9424fec57ef34cd Mon Sep 17 00:00:00 2001 From: Edward Houston Date: Fri, 20 Feb 2026 10:06:55 +0100 Subject: [PATCH 1/3] feat: add dynamic JWT authorization support via callback provider --- examples/jwt_auth.rs | 115 ++++++++++++++++++++ src/client.rs | 38 +++++-- src/config.rs | 137 +++++++++++++++++++++++- src/raw_client.rs | 243 ++++++++++++++++++++++++++++++++++++++++--- src/types.rs | 61 +++++++++++ 5 files changed, 572 insertions(+), 22 deletions(-) create mode 100644 examples/jwt_auth.rs diff --git a/examples/jwt_auth.rs b/examples/jwt_auth.rs new file mode 100644 index 0000000..a586a7b --- /dev/null +++ b/examples/jwt_auth.rs @@ -0,0 +1,115 @@ +//! # JWT Authentication with Electrum Client +//! +//! This example demonstrates how to use dynamic JWT authentication with the +//! electrum-client library. +//! +//! ## Overview +//! +//! The electrum-client supports embedding authorization tokens (such as JWT +//! Bearer tokens) directly in JSON-RPC requests. This is achieved through an +//! [`AuthProvider`](electrum_client::config::AuthProvider) callback that is +//! invoked before each request. +//! +//! ## Advanced: Token Refresh with Keycloak +//! +//! For automatic token refresh (e.g., every 4 minutes before a 5-minute +//! expiration), use a shared token holder behind an `Arc>` and +//! spawn a background task to refresh it: +//! +//! ```rust,no_run +//! use electrum_client::{Client, ConfigBuilder}; +//! use std::sync::{Arc, RwLock}; +//! +//! let token = Arc::new(RwLock::new(Some("Bearer initial-token".to_string()))); +//! let token_for_provider = token.clone(); +//! +//! let config = ConfigBuilder::new() +//! .authorization_provider(Some(Arc::new(move || { +//! token_for_provider.read().unwrap().clone() +//! }))) +//! .build(); +//! +//! let client = Client::from_config("ssl://your-server:50002", config).unwrap(); +//! +//! // In a background thread/task, periodically update the token: +//! // *token.write().unwrap() = Some("Bearer refreshed-token".to_string()); +//! ``` +//! +//! ## Integration with BDK +//! +//! To use with BDK, create the electrum client with your config, then wrap it: +//! +//! ```rust,ignore +//! use bdk_electrum::BdkElectrumClient; +//! use electrum_client::{Client, ConfigBuilder}; +//! use std::sync::Arc; +//! use std::time::Duration; +//! +//! let config = ConfigBuilder::new() +//! .authorization_provider(Some(Arc::new(move || { +//! token_manager.get_token() +//! }))) +//! .timeout(Some(Duration::from_secs(30))) +//! .build(); +//! +//! let electrum_client = Client::from_config("ssl://your-api-gateway:50002", config)?; +//! let bdk_client = BdkElectrumClient::new(electrum_client); +//! ``` +//! +//! ## JSON-RPC Request Format +//! +//! With the auth provider configured, each JSON-RPC request will include the +//! authorization field: +//! +//! ```json +//! { +//! "jsonrpc": "2.0", +//! "method": "blockchain.headers.subscribe", +//! "params": [], +//! "id": 1, +//! "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +//! } +//! ``` +//! +//! If the provider returns `None`, the authorization field is omitted from the +//! request. +//! +//! ## Thread Safety +//! +//! The `AuthProvider` type is defined as: +//! +//! ```rust,ignore +//! pub type AuthProvider = Arc Option + Send + Sync>; +//! ``` +//! +//! This ensures thread-safe access to tokens across all RPC calls. + +extern crate electrum_client; + +use electrum_client::{Client, ConfigBuilder, ElectrumApi}; +use std::sync::Arc; + +fn main() { + // Example: Static JWT token + let config = ConfigBuilder::new() + .authorization_provider(Some(Arc::new(|| { + // In production, fetch this from your token manager + Some("Bearer example-jwt-token-12345".to_string()) + }))) + .build(); + + match Client::from_config("tcp://localhost:50001", config) { + Ok(client) => { + println!("Connected to server with JWT auth"); + match client.server_features() { + Ok(features) => println!("Server features: {:#?}", features), + Err(e) => eprintln!("Error fetching features: {}", e), + } + } + Err(e) => { + eprintln!("Connection error: {}", e); + eprintln!("\nNote: This example requires an Electrum server that accepts JWT auth."); + eprintln!("Update the URL and token to match your setup."); + } + } +} diff --git a/src/client.rs b/src/client.rs index cb59a4d..14e315e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -112,24 +112,34 @@ impl ClientType { /// Constructor that supports multiple backends and allows configuration through /// the [Config] pub fn from_config(url: &str, config: &Config) -> Result { + let auth = config.authorization_provider().clone(); + #[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))] if url.starts_with("ssl://") { let url = url.replacen("ssl://", "", 1); #[cfg(feature = "proxy")] let client = match config.socks5() { - Some(socks5) => RawClient::new_proxy_ssl( + Some(socks5) => RawClient::new_proxy_ssl_with_auth( url.as_str(), config.validate_domain(), socks5, config.timeout(), + auth, + )?, + None => RawClient::new_ssl_with_auth( + url.as_str(), + config.validate_domain(), + config.timeout(), + auth, )?, - None => { - RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())? - } }; #[cfg(not(feature = "proxy"))] - let client = - RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())?; + let client = RawClient::new_ssl_with_auth( + url.as_str(), + config.validate_domain(), + config.timeout(), + auth, + )?; return Ok(ClientType::SSL(client)); } @@ -143,18 +153,28 @@ impl ClientType { { let url = url.replacen("tcp://", "", 1); + #[cfg(feature = "proxy")] let client = match config.socks5() { - Some(socks5) => ClientType::Socks5(RawClient::new_proxy( + Some(socks5) => ClientType::Socks5(RawClient::new_proxy_with_auth( url.as_str(), socks5, config.timeout(), + auth, + )?), + None => ClientType::TCP(RawClient::new_with_auth( + url.as_str(), + config.timeout(), + auth, )?), - None => ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?), }; #[cfg(not(feature = "proxy"))] - let client = ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?); + let client = ClientType::TCP(RawClient::new_with_auth( + url.as_str(), + config.timeout(), + auth, + )?); Ok(client) } diff --git a/src/config.rs b/src/config.rs index e4c5770..d644d3d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,12 +1,16 @@ +use std::sync::Arc; use std::time::Duration; +/// A function that provides authorization tokens dynamically (e.g., for JWT refresh) +pub type AuthProvider = Arc Option + Send + Sync>; + /// Configuration for an electrum client /// /// Refer to [`Client::from_config`] and [`ClientType::from_config`]. /// /// [`Client::from_config`]: crate::Client::from_config /// [`ClientType::from_config`]: crate::ClientType::from_config -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Config { /// Proxy socks5 configuration, default None socks5: Option, @@ -16,6 +20,24 @@ pub struct Config { retry: u8, /// when ssl, validate the domain, default true validate_domain: bool, + /// Optional authorization provider for dynamic token injection + authorization_provider: Option, +} + +// Custom Debug impl because AuthProvider doesn't implement Debug +impl std::fmt::Debug for Config { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Config") + .field("socks5", &self.socks5) + .field("timeout", &self.timeout) + .field("retry", &self.retry) + .field("validate_domain", &self.validate_domain) + .field( + "authorization_provider", + &self.authorization_provider.as_ref().map(|_| ""), + ) + .finish() + } } /// Configuration for Socks5 @@ -72,6 +94,12 @@ impl ConfigBuilder { self } + /// Sets the authorization provider for dynamic token injection + pub fn authorization_provider(mut self, provider: Option) -> Self { + self.config.authorization_provider = provider; + self + } + /// Return the config and consume the builder pub fn build(self) -> Config { self.config @@ -131,6 +159,13 @@ impl Config { self.validate_domain } + /// Get the configuration for `authorization_provider` + /// + /// Set this with [`ConfigBuilder::authorization_provider`] + pub fn authorization_provider(&self) -> &Option { + &self.authorization_provider + } + /// Convenience method for calling [`ConfigBuilder::new`] pub fn builder() -> ConfigBuilder { ConfigBuilder::new() @@ -144,6 +179,106 @@ impl Default for Config { timeout: None, retry: 1, validate_domain: true, + authorization_provider: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_authorization_provider_builder() { + let token = "test-token-123".to_string(); + let provider = Arc::new(move || Some(format!("Bearer {}", token))); + + let config = ConfigBuilder::new() + .authorization_provider(Some(provider.clone())) + .build(); + + assert!(config.authorization_provider().is_some()); + + // Test that the provider returns the expected value + if let Some(auth_provider) = config.authorization_provider() { + assert_eq!(auth_provider(), Some("Bearer test-token-123".to_string())); } } + + #[test] + fn test_authorization_provider_none() { + let config = ConfigBuilder::new().build(); + + assert!(config.authorization_provider().is_none()); + } + + #[test] + fn test_authorization_provider_returns_none() { + let provider = Arc::new(|| None); + + let config = ConfigBuilder::new() + .authorization_provider(Some(provider)) + .build(); + + assert!(config.authorization_provider().is_some()); + + // Test that the provider returns None + if let Some(auth_provider) = config.authorization_provider() { + assert_eq!(auth_provider(), None); + } + } + + #[test] + fn test_authorization_provider_dynamic_token() { + use std::sync::RwLock; + + // Simulate a token that can be updated + let token = Arc::new(RwLock::new("initial-token".to_string())); + let token_clone = token.clone(); + + let provider = Arc::new(move || Some(token_clone.read().unwrap().clone())); + + let config = ConfigBuilder::new() + .authorization_provider(Some(provider.clone())) + .build(); + + // Initial token + if let Some(auth_provider) = config.authorization_provider() { + assert_eq!(auth_provider(), Some("initial-token".to_string())); + } + + // Update the token + *token.write().unwrap() = "refreshed-token".to_string(); + + // Provider should return the new token + if let Some(auth_provider) = config.authorization_provider() { + assert_eq!(auth_provider(), Some("refreshed-token".to_string())); + } + } + + #[test] + fn test_config_debug_with_provider() { + let provider = Arc::new(|| Some("secret-token".to_string())); + + let config = ConfigBuilder::new() + .authorization_provider(Some(provider)) + .build(); + + let debug_str = format!("{:?}", config); + + // Should show instead of the actual function pointer + assert!(debug_str.contains("")); + // Should not leak the token value + assert!(!debug_str.contains("secret-token")); + } + + #[test] + fn test_config_debug_without_provider() { + let config = ConfigBuilder::new().build(); + + let debug_str = format!("{:?}", config); + + // Should show None for authorization_provider + assert!(debug_str.contains("authorization_provider")); + } } diff --git a/src/raw_client.rs b/src/raw_client.rs index 68f5a63..3fbcd4f 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -37,6 +37,7 @@ use crate::stream::ClonableStream; use crate::api::ElectrumApi; use crate::batch::Batch; +use crate::config::AuthProvider; use crate::types::*; /// Client name sent to the server during protocol version negotiation. @@ -142,7 +143,6 @@ impl_to_socket_addrs_domain!((std::net::Ipv6Addr, u16)); /// /// More transport methods can be used by manually creating an instance of this struct with an /// arbitray `S` type. -#[derive(Debug)] pub struct RawClient where S: Read + Write, @@ -159,9 +159,33 @@ where /// The protocol version negotiated with the server via `server.version`. protocol_version: Mutex>, + /// Optional authorization provider for dynamic token injection (e.g., JWT). + auth_provider: Option, + calls: AtomicUsize, } +// Custom Debug impl because AuthProvider doesn't implement Debug +impl std::fmt::Debug for RawClient +where + S: Read + Write, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawClient") + .field("stream", &"") + .field("buf_reader", &"") + .field("last_id", &self.last_id) + .field("waiting_map", &self.waiting_map) + .field("headers", &self.headers) + .field("script_notifications", &self.script_notifications) + .field( + "auth_provider", + &self.auth_provider.as_ref().map(|_| ""), + ) + .finish() + } +} + impl From for RawClient where S: Read + Write, @@ -181,6 +205,8 @@ where protocol_version: Mutex::new(None), + auth_provider: None, + calls: AtomicUsize::new(0), } } @@ -196,6 +222,14 @@ impl RawClient { pub fn new( socket_addrs: A, timeout: Option, + ) -> Result { + Self::new_with_auth(socket_addrs, timeout, None) + } + + pub(crate) fn new_with_auth( + socket_addrs: A, + timeout: Option, + auth_provider: Option, ) -> Result { let stream = match timeout { Some(timeout) => { @@ -207,7 +241,8 @@ impl RawClient { None => TcpStream::connect(socket_addrs)?, }; - let client: Self = stream.into(); + let mut client: Self = stream.into(); + client.auth_provider = auth_provider; client.negotiate_protocol_version()?; Ok(client) } @@ -261,6 +296,15 @@ impl RawClient { socket_addrs: A, validate_domain: bool, timeout: Option, + ) -> Result { + Self::new_ssl_with_auth(socket_addrs, validate_domain, timeout, None) + } + + pub(crate) fn new_ssl_with_auth( + socket_addrs: A, + validate_domain: bool, + timeout: Option, + auth_provider: Option, ) -> Result { debug!( "new_ssl socket_addrs.domain():{:?} validate_domain:{} timeout:{:?}", @@ -276,11 +320,21 @@ impl RawClient { let stream = connect_with_total_timeout(socket_addrs.clone(), timeout)?; stream.set_read_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + Self::new_ssl_from_stream_with_auth( + socket_addrs, + validate_domain, + stream, + auth_provider, + ) } None => { let stream = TcpStream::connect(socket_addrs.clone())?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + Self::new_ssl_from_stream_with_auth( + socket_addrs, + validate_domain, + stream, + auth_provider, + ) } } } @@ -290,6 +344,15 @@ impl RawClient { socket_addrs: A, validate_domain: bool, stream: TcpStream, + ) -> Result { + Self::new_ssl_from_stream_with_auth(socket_addrs, validate_domain, stream, None) + } + + pub(crate) fn new_ssl_from_stream_with_auth( + socket_addrs: A, + validate_domain: bool, + stream: TcpStream, + auth_provider: Option, ) -> Result { let mut builder = SslConnector::builder(SslMethod::tls()).map_err(Error::InvalidSslMethod)?; @@ -307,7 +370,8 @@ impl RawClient { .connect(&domain, stream) .map_err(Error::SslHandshakeError)?; - let client: Self = stream.into(); + let mut client: Self = stream.into(); + client.auth_provider = auth_provider; client.negotiate_protocol_version()?; Ok(client) } @@ -384,6 +448,15 @@ impl RawClient { socket_addrs: A, validate_domain: bool, timeout: Option, + ) -> Result { + Self::new_ssl_with_auth(socket_addrs, validate_domain, timeout, None) + } + + pub(crate) fn new_ssl_with_auth( + socket_addrs: A, + validate_domain: bool, + timeout: Option, + auth_provider: Option, ) -> Result { debug!( "new_ssl socket_addrs.domain():{:?} validate_domain:{} timeout:{:?}", @@ -399,11 +472,21 @@ impl RawClient { let stream = connect_with_total_timeout(socket_addrs.clone(), timeout)?; stream.set_read_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + Self::new_ssl_from_stream_with_auth( + socket_addrs, + validate_domain, + stream, + auth_provider, + ) } None => { let stream = TcpStream::connect(socket_addrs.clone())?; - Self::new_ssl_from_stream(socket_addrs, validate_domain, stream) + Self::new_ssl_from_stream_with_auth( + socket_addrs, + validate_domain, + stream, + auth_provider, + ) } } } @@ -413,6 +496,15 @@ impl RawClient { socket_addr: A, validate_domain: bool, tcp_stream: TcpStream, + ) -> Result { + Self::new_ssl_from_stream_with_auth(socket_addr, validate_domain, tcp_stream, None) + } + + pub(crate) fn new_ssl_from_stream_with_auth( + socket_addr: A, + validate_domain: bool, + tcp_stream: TcpStream, + auth_provider: Option, ) -> Result { use std::convert::TryFrom; @@ -476,7 +568,8 @@ impl RawClient { .map_err(Error::CouldNotCreateConnection)?; let stream = StreamOwned::new(session, tcp_stream); - let client: Self = stream.into(); + let mut client: Self = stream.into(); + client.auth_provider = auth_provider; client.negotiate_protocol_version()?; Ok(client) } @@ -494,6 +587,15 @@ impl RawClient { target_addr: T, proxy: &crate::Socks5Config, timeout: Option, + ) -> Result { + Self::new_proxy_with_auth(target_addr, proxy, timeout, None) + } + + pub(crate) fn new_proxy_with_auth( + target_addr: T, + proxy: &crate::Socks5Config, + timeout: Option, + auth_provider: Option, ) -> Result { let mut stream = match proxy.credentials.as_ref() { Some(cred) => Socks5Stream::connect_with_password( @@ -508,7 +610,8 @@ impl RawClient { stream.get_mut().set_read_timeout(timeout)?; stream.get_mut().set_write_timeout(timeout)?; - let client: Self = stream.into(); + let mut client: Self = stream.into(); + client.auth_provider = auth_provider; client.negotiate_protocol_version()?; Ok(client) } @@ -525,6 +628,20 @@ impl RawClient { validate_domain: bool, proxy: &crate::Socks5Config, timeout: Option, + ) -> Result, Error> { + Self::new_proxy_ssl_with_auth(target_addr, validate_domain, proxy, timeout, None) + } + + #[cfg(all( + any(feature = "openssl", feature = "rustls", feature = "rustls-ring",), + feature = "proxy", + ))] + pub(crate) fn new_proxy_ssl_with_auth( + target_addr: T, + validate_domain: bool, + proxy: &crate::Socks5Config, + timeout: Option, + auth_provider: Option, ) -> Result, Error> { let target = target_addr.to_target_addr()?; @@ -541,7 +658,12 @@ impl RawClient { stream.get_mut().set_read_timeout(timeout)?; stream.get_mut().set_write_timeout(timeout)?; - RawClient::new_ssl_from_stream(target, validate_domain, stream.into_inner()) + RawClient::new_ssl_from_stream_with_auth( + target, + validate_domain, + stream.into_inner(), + auth_provider, + ) } } @@ -553,6 +675,13 @@ enum ChannelMessage { } impl RawClient { + /// Sets the authorization provider for dynamic token injection. + /// This should be called immediately after constructing the client. + pub fn with_auth_provider(mut self, provider: Option) -> Self { + self.auth_provider = provider; + self + } + // TODO: to enable this we have to find a way to allow concurrent read and writes to the // underlying transport struct. This can be done pretty easily for TcpStream because it can be // split into a "read" and a "write" object, but it's not as trivial for other types. Without @@ -715,6 +844,12 @@ impl RawClient { let (sender, receiver) = channel(); self.waiting_map.lock()?.insert(req.id, sender); + // Apply authorization token if provider is set + let mut req = req; + if let Some(provider) = &self.auth_provider { + req.authorization = provider(); + } + let mut raw = serde_json::to_vec(&req)?; trace!("==> {}", String::from_utf8_lossy(&raw)); @@ -832,12 +967,20 @@ impl ElectrumApi for RawClient { // Add our listener to the map before we send the request - for (method, params) in batch.iter() { - let req = Request::new_id( + for (index, (method, params)) in batch.iter().enumerate() { + let mut req = Request::new_id( self.last_id.fetch_add(1, Ordering::SeqCst), method, params.to_vec(), ); + + // Apply authorization token only to the first request in batch + if index == 0 { + if let Some(provider) = &self.auth_provider { + req.authorization = provider(); + } + } + // Add distinct channel to each request so when we remove our request id (and sender) from the waiting_map // we can be sure that the response gets sent to the correct channel in self.recv let (sender, receiver) = channel(); @@ -1800,4 +1943,80 @@ mod test { 00000" ) } + + #[test] + fn test_authorization_provider_with_client() { + use std::sync::{Arc, RwLock}; + + // Track how many times the provider is called + let call_count = Arc::new(RwLock::new(0)); + let call_count_clone = call_count.clone(); + + let provider = Arc::new(move || { + *call_count_clone.write().unwrap() += 1; + Some("Bearer test-token-123".to_string()) + }); + + let mut client = get_test_client(); + client = client.with_auth_provider(Some(provider)); + + // Make a request - provider should be called + let _ = client.server_features(); + + // Provider should have been called at least once + assert!(*call_count.read().unwrap() >= 1); + } + + #[test] + fn test_authorization_provider_dynamic_token_refresh() { + use std::sync::{Arc, RwLock}; + + // Simulate a token that can be refreshed + let token = Arc::new(RwLock::new("initial-token".to_string())); + let token_clone = token.clone(); + + let provider = Arc::new(move || Some(token_clone.read().unwrap().clone())); + + let mut client = get_test_client(); + client = client.with_auth_provider(Some(provider.clone())); + + // Make first request with initial token + let _ = client.server_features(); + + // Simulate token refresh + *token.write().unwrap() = "refreshed-token".to_string(); + + // Make second request - should use the new token + let _ = client.server_features(); + + // Verify the provider now returns the refreshed token + assert_eq!(provider(), Some("refreshed-token".to_string())); + } + + #[test] + fn test_authorization_provider_returns_none() { + use std::sync::Arc; + + let provider = Arc::new(|| None); + + let mut client = get_test_client(); + client = client.with_auth_provider(Some(provider)); + + // Should still work when provider returns None + let result = client.server_features(); + assert!(result.is_ok()); + } + + #[test] + fn test_with_auth_provider_method_chaining() { + use std::sync::Arc; + + let provider = Arc::new(|| Some("Bearer test".to_string())); + + let client = get_test_client().with_auth_provider(Some(provider.clone())); + + // Verify the provider was set + let result = client.server_features(); + assert!(result.is_ok()); + } } diff --git a/src/types.rs b/src/types.rs index 0714314..5221657 100644 --- a/src/types.rs +++ b/src/types.rs @@ -71,6 +71,9 @@ pub struct Request<'a> { pub method: &'a str, /// The request parameters pub params: Vec, + /// Optional authorization token (e.g., JWT Bearer token) + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization: Option, } impl<'a> Request<'a> { @@ -81,6 +84,7 @@ impl<'a> Request<'a> { jsonrpc: JSONRPC_2_0, method, params, + authorization: None, } } @@ -518,6 +522,8 @@ impl From for Error { mod tests { use crate::ScriptStatus; + use super::{Param, Request}; + #[test] fn script_status_roundtrip() { let script_status: ScriptStatus = [1u8; 32].into(); @@ -525,4 +531,59 @@ mod tests { let script_status_back = serde_json::from_str(&script_status_json).unwrap(); assert_eq!(script_status, script_status_back); } + + #[test] + fn test_request_serialization_without_authorization() { + let req = Request::new_id(1, "server.version", vec![]); + + let json = serde_json::to_string(&req).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + // Authorization field should not be present when None + assert!(parsed.get("authorization").is_none()); + assert!(!json.contains("authorization")); + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["method"], "server.version"); + assert_eq!(parsed["id"], 1); + } + + #[test] + fn test_request_serialization_with_authorization() { + let mut req = Request::new_id(1, "server.version", vec![]); + req.authorization = Some("Bearer test-jwt-token".to_string()); + + let json = serde_json::to_string(&req).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + // Authorization field should be present + assert_eq!( + parsed["authorization"], + serde_json::Value::String("Bearer test-jwt-token".to_string()) + ); + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["method"], "server.version"); + assert_eq!(parsed["id"], 1); + } + + #[test] + fn test_request_with_params_and_authorization() { + let mut req = Request::new_id( + 42, + "blockchain.scripthash.get_balance", + vec![Param::String("test-scripthash".to_string())], + ); + req.authorization = Some("Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9".to_string()); + + let json = serde_json::to_string(&req).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["id"], 42); + assert_eq!(parsed["method"], "blockchain.scripthash.get_balance"); + assert_eq!( + parsed["authorization"], + "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" + ); + assert!(parsed["params"].is_array()); + assert_eq!(parsed["params"][0], "test-scripthash"); + } } From 0576c2577e71cf08234e504346e4656dbe33c994 Mon Sep 17 00:00:00 2001 From: Edward Houston Date: Tue, 10 Mar 2026 19:36:01 +0100 Subject: [PATCH 2/3] Addressing latest review comments --- examples/jwt_auth.rs | 21 --------------------- src/client.rs | 2 +- src/config.rs | 4 ++-- src/raw_client.rs | 34 ++++++++++++++++------------------ 4 files changed, 19 insertions(+), 42 deletions(-) diff --git a/examples/jwt_auth.rs b/examples/jwt_auth.rs index a586a7b..b20bd9e 100644 --- a/examples/jwt_auth.rs +++ b/examples/jwt_auth.rs @@ -35,27 +35,6 @@ //! // *token.write().unwrap() = Some("Bearer refreshed-token".to_string()); //! ``` //! -//! ## Integration with BDK -//! -//! To use with BDK, create the electrum client with your config, then wrap it: -//! -//! ```rust,ignore -//! use bdk_electrum::BdkElectrumClient; -//! use electrum_client::{Client, ConfigBuilder}; -//! use std::sync::Arc; -//! use std::time::Duration; -//! -//! let config = ConfigBuilder::new() -//! .authorization_provider(Some(Arc::new(move || { -//! token_manager.get_token() -//! }))) -//! .timeout(Some(Duration::from_secs(30))) -//! .build(); -//! -//! let electrum_client = Client::from_config("ssl://your-api-gateway:50002", config)?; -//! let bdk_client = BdkElectrumClient::new(electrum_client); -//! ``` -//! //! ## JSON-RPC Request Format //! //! With the auth provider configured, each JSON-RPC request will include the diff --git a/src/client.rs b/src/client.rs index 14e315e..198dfac 100644 --- a/src/client.rs +++ b/src/client.rs @@ -112,7 +112,7 @@ impl ClientType { /// Constructor that supports multiple backends and allows configuration through /// the [Config] pub fn from_config(url: &str, config: &Config) -> Result { - let auth = config.authorization_provider().clone(); + let auth = config.authorization_provider().cloned(); #[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))] if url.starts_with("ssl://") { diff --git a/src/config.rs b/src/config.rs index d644d3d..b3e6d7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,8 +162,8 @@ impl Config { /// Get the configuration for `authorization_provider` /// /// Set this with [`ConfigBuilder::authorization_provider`] - pub fn authorization_provider(&self) -> &Option { - &self.authorization_provider + pub fn authorization_provider(&self) -> Option<&AuthProvider> { + self.authorization_provider.as_ref() } /// Convenience method for calling [`ConfigBuilder::new`] diff --git a/src/raw_client.rs b/src/raw_client.rs index 3fbcd4f..05a10ef 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -675,13 +675,6 @@ enum ChannelMessage { } impl RawClient { - /// Sets the authorization provider for dynamic token injection. - /// This should be called immediately after constructing the client. - pub fn with_auth_provider(mut self, provider: Option) -> Self { - self.auth_provider = provider; - self - } - // TODO: to enable this we have to find a way to allow concurrent read and writes to the // underlying transport struct. This can be done pretty easily for TcpStream because it can be // split into a "read" and a "write" object, but it's not as trivial for other types. Without @@ -1458,6 +1451,7 @@ mod test { use super::{ElectrumSslStream, RawClient}; use crate::api::ElectrumApi; + use crate::config::AuthProvider; fn get_test_client() -> RawClient { let server = @@ -1465,6 +1459,12 @@ mod test { RawClient::new_ssl(&*server, false, None).unwrap() } + fn get_test_client_with_auth(provider: AuthProvider) -> RawClient { + let server = + std::env::var("TEST_ELECTRUM_SERVER").unwrap_or("fortress.qtornado.com:443".into()); + RawClient::new_ssl_with_auth(&*server, false, None, Some(provider)).unwrap() + } + #[test] fn test_server_features_simple() { let client = get_test_client(); @@ -1957,8 +1957,7 @@ mod test { Some("Bearer test-token-123".to_string()) }); - let mut client = get_test_client(); - client = client.with_auth_provider(Some(provider)); + let client = get_test_client_with_auth(provider); // Make a request - provider should be called let _ = client.server_features(); @@ -1975,10 +1974,10 @@ mod test { let token = Arc::new(RwLock::new("initial-token".to_string())); let token_clone = token.clone(); - let provider = Arc::new(move || Some(token_clone.read().unwrap().clone())); + let provider: AuthProvider = + Arc::new(move || Some(token_clone.read().unwrap().clone())); - let mut client = get_test_client(); - client = client.with_auth_provider(Some(provider.clone())); + let client = get_test_client_with_auth(provider.clone()); // Make first request with initial token let _ = client.server_features(); @@ -1997,10 +1996,9 @@ mod test { fn test_authorization_provider_returns_none() { use std::sync::Arc; - let provider = Arc::new(|| None); + let provider: AuthProvider = Arc::new(|| None); - let mut client = get_test_client(); - client = client.with_auth_provider(Some(provider)); + let client = get_test_client_with_auth(provider); // Should still work when provider returns None let result = client.server_features(); @@ -2008,12 +2006,12 @@ mod test { } #[test] - fn test_with_auth_provider_method_chaining() { + fn test_auth_provider_via_constructor() { use std::sync::Arc; - let provider = Arc::new(|| Some("Bearer test".to_string())); + let provider: AuthProvider = Arc::new(|| Some("Bearer test".to_string())); - let client = get_test_client().with_auth_provider(Some(provider.clone())); + let client = get_test_client_with_auth(provider); // Verify the provider was set let result = client.server_features(); From 8d11b287b48b923446e7686b2563d86364ceaebc Mon Sep 17 00:00:00 2001 From: Edward Houston Date: Tue, 10 Mar 2026 20:39:15 +0100 Subject: [PATCH 3/3] Adding clearer comment for batch auth on first request --- src/raw_client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/raw_client.rs b/src/raw_client.rs index 05a10ef..5ba92db 100644 --- a/src/raw_client.rs +++ b/src/raw_client.rs @@ -967,7 +967,7 @@ impl ElectrumApi for RawClient { params.to_vec(), ); - // Apply authorization token only to the first request in batch + // Servers expect auth token only in the first request in batched requests if index == 0 { if let Some(provider) = &self.auth_provider { req.authorization = provider();