Skip to content

Commit 020f193

Browse files
committed
feat: add dynamic JWT authorization support via callback provider
1 parent cdb89a2 commit 020f193

5 files changed

Lines changed: 572 additions & 22 deletions

File tree

examples/jwt_auth.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! # JWT Authentication with Electrum Client
2+
//!
3+
//! This example demonstrates how to use dynamic JWT authentication with the
4+
//! electrum-client library.
5+
//!
6+
//! ## Overview
7+
//!
8+
//! The electrum-client supports embedding authorization tokens (such as JWT
9+
//! Bearer tokens) directly in JSON-RPC requests. This is achieved through an
10+
//! [`AuthProvider`](electrum_client::config::AuthProvider) callback that is
11+
//! invoked before each request.
12+
//!
13+
//! ## Advanced: Token Refresh with Keycloak
14+
//!
15+
//! For automatic token refresh (e.g., every 4 minutes before a 5-minute
16+
//! expiration), use a shared token holder behind an `Arc<RwLock<...>>` and
17+
//! spawn a background task to refresh it:
18+
//!
19+
//! ```rust,no_run
20+
//! use electrum_client::{Client, ConfigBuilder};
21+
//! use std::sync::{Arc, RwLock};
22+
//!
23+
//! let token = Arc::new(RwLock::new(Some("Bearer initial-token".to_string())));
24+
//! let token_for_provider = token.clone();
25+
//!
26+
//! let config = ConfigBuilder::new()
27+
//! .authorization_provider(Some(Arc::new(move || {
28+
//! token_for_provider.read().unwrap().clone()
29+
//! })))
30+
//! .build();
31+
//!
32+
//! let client = Client::from_config("ssl://your-server:50002", config).unwrap();
33+
//!
34+
//! // In a background thread/task, periodically update the token:
35+
//! // *token.write().unwrap() = Some("Bearer refreshed-token".to_string());
36+
//! ```
37+
//!
38+
//! ## Integration with BDK
39+
//!
40+
//! To use with BDK, create the electrum client with your config, then wrap it:
41+
//!
42+
//! ```rust,ignore
43+
//! use bdk_electrum::BdkElectrumClient;
44+
//! use electrum_client::{Client, ConfigBuilder};
45+
//! use std::sync::Arc;
46+
//! use std::time::Duration;
47+
//!
48+
//! let config = ConfigBuilder::new()
49+
//! .authorization_provider(Some(Arc::new(move || {
50+
//! token_manager.get_token()
51+
//! })))
52+
//! .timeout(Some(Duration::from_secs(30)))
53+
//! .build();
54+
//!
55+
//! let electrum_client = Client::from_config("ssl://your-api-gateway:50002", config)?;
56+
//! let bdk_client = BdkElectrumClient::new(electrum_client);
57+
//! ```
58+
//!
59+
//! ## JSON-RPC Request Format
60+
//!
61+
//! With the auth provider configured, each JSON-RPC request will include the
62+
//! authorization field:
63+
//!
64+
//! ```json
65+
//! {
66+
//! "jsonrpc": "2.0",
67+
//! "method": "blockchain.headers.subscribe",
68+
//! "params": [],
69+
//! "id": 1,
70+
//! "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
71+
//! }
72+
//! ```
73+
//!
74+
//! If the provider returns `None`, the authorization field is omitted from the
75+
//! request.
76+
//!
77+
//! ## Thread Safety
78+
//!
79+
//! The `AuthProvider` type is defined as:
80+
//!
81+
//! ```rust,ignore
82+
//! pub type AuthProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
83+
//! ```
84+
//!
85+
//! This ensures thread-safe access to tokens across all RPC calls.
86+
87+
extern crate electrum_client;
88+
89+
use electrum_client::{Client, ConfigBuilder, ElectrumApi};
90+
use std::sync::Arc;
91+
92+
fn main() {
93+
// Example: Static JWT token
94+
let config = ConfigBuilder::new()
95+
.authorization_provider(Some(Arc::new(|| {
96+
// In production, fetch this from your token manager
97+
Some("Bearer example-jwt-token-12345".to_string())
98+
})))
99+
.build();
100+
101+
match Client::from_config("tcp://localhost:50001", config) {
102+
Ok(client) => {
103+
println!("Connected to server with JWT auth");
104+
match client.server_features() {
105+
Ok(features) => println!("Server features: {:#?}", features),
106+
Err(e) => eprintln!("Error fetching features: {}", e),
107+
}
108+
}
109+
Err(e) => {
110+
eprintln!("Connection error: {}", e);
111+
eprintln!("\nNote: This example requires an Electrum server that accepts JWT auth.");
112+
eprintln!("Update the URL and token to match your setup.");
113+
}
114+
}
115+
}

src/client.rs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,24 +112,34 @@ impl ClientType {
112112
/// Constructor that supports multiple backends and allows configuration through
113113
/// the [Config]
114114
pub fn from_config(url: &str, config: &Config) -> Result<Self, Error> {
115+
let auth = config.authorization_provider().clone();
116+
115117
#[cfg(any(feature = "openssl", feature = "rustls", feature = "rustls-ring"))]
116118
if url.starts_with("ssl://") {
117119
let url = url.replacen("ssl://", "", 1);
118120
#[cfg(feature = "proxy")]
119121
let client = match config.socks5() {
120-
Some(socks5) => RawClient::new_proxy_ssl(
122+
Some(socks5) => RawClient::new_proxy_ssl_with_auth(
121123
url.as_str(),
122124
config.validate_domain(),
123125
socks5,
124126
config.timeout(),
127+
auth,
128+
)?,
129+
None => RawClient::new_ssl_with_auth(
130+
url.as_str(),
131+
config.validate_domain(),
132+
config.timeout(),
133+
auth,
125134
)?,
126-
None => {
127-
RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())?
128-
}
129135
};
130136
#[cfg(not(feature = "proxy"))]
131-
let client =
132-
RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())?;
137+
let client = RawClient::new_ssl_with_auth(
138+
url.as_str(),
139+
config.validate_domain(),
140+
config.timeout(),
141+
auth,
142+
)?;
133143

134144
return Ok(ClientType::SSL(client));
135145
}
@@ -143,18 +153,28 @@ impl ClientType {
143153

144154
{
145155
let url = url.replacen("tcp://", "", 1);
156+
146157
#[cfg(feature = "proxy")]
147158
let client = match config.socks5() {
148-
Some(socks5) => ClientType::Socks5(RawClient::new_proxy(
159+
Some(socks5) => ClientType::Socks5(RawClient::new_proxy_with_auth(
149160
url.as_str(),
150161
socks5,
151162
config.timeout(),
163+
auth,
164+
)?),
165+
None => ClientType::TCP(RawClient::new_with_auth(
166+
url.as_str(),
167+
config.timeout(),
168+
auth,
152169
)?),
153-
None => ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?),
154170
};
155171

156172
#[cfg(not(feature = "proxy"))]
157-
let client = ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?);
173+
let client = ClientType::TCP(RawClient::new_with_auth(
174+
url.as_str(),
175+
config.timeout(),
176+
auth,
177+
)?);
158178

159179
Ok(client)
160180
}

src/config.rs

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
use std::sync::Arc;
12
use std::time::Duration;
23

4+
/// A function that provides authorization tokens dynamically (e.g., for JWT refresh)
5+
pub type AuthProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
6+
37
/// Configuration for an electrum client
48
///
59
/// Refer to [`Client::from_config`] and [`ClientType::from_config`].
610
///
711
/// [`Client::from_config`]: crate::Client::from_config
812
/// [`ClientType::from_config`]: crate::ClientType::from_config
9-
#[derive(Debug, Clone)]
13+
#[derive(Clone)]
1014
pub struct Config {
1115
/// Proxy socks5 configuration, default None
1216
socks5: Option<Socks5Config>,
@@ -16,6 +20,24 @@ pub struct Config {
1620
retry: u8,
1721
/// when ssl, validate the domain, default true
1822
validate_domain: bool,
23+
/// Optional authorization provider for dynamic token injection
24+
authorization_provider: Option<AuthProvider>,
25+
}
26+
27+
// Custom Debug impl because AuthProvider doesn't implement Debug
28+
impl std::fmt::Debug for Config {
29+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30+
f.debug_struct("Config")
31+
.field("socks5", &self.socks5)
32+
.field("timeout", &self.timeout)
33+
.field("retry", &self.retry)
34+
.field("validate_domain", &self.validate_domain)
35+
.field(
36+
"authorization_provider",
37+
&self.authorization_provider.as_ref().map(|_| "<provider>"),
38+
)
39+
.finish()
40+
}
1941
}
2042

2143
/// Configuration for Socks5
@@ -72,6 +94,12 @@ impl ConfigBuilder {
7294
self
7395
}
7496

97+
/// Sets the authorization provider for dynamic token injection
98+
pub fn authorization_provider(mut self, provider: Option<AuthProvider>) -> Self {
99+
self.config.authorization_provider = provider;
100+
self
101+
}
102+
75103
/// Return the config and consume the builder
76104
pub fn build(self) -> Config {
77105
self.config
@@ -131,6 +159,13 @@ impl Config {
131159
self.validate_domain
132160
}
133161

162+
/// Get the configuration for `authorization_provider`
163+
///
164+
/// Set this with [`ConfigBuilder::authorization_provider`]
165+
pub fn authorization_provider(&self) -> &Option<AuthProvider> {
166+
&self.authorization_provider
167+
}
168+
134169
/// Convenience method for calling [`ConfigBuilder::new`]
135170
pub fn builder() -> ConfigBuilder {
136171
ConfigBuilder::new()
@@ -144,6 +179,106 @@ impl Default for Config {
144179
timeout: None,
145180
retry: 1,
146181
validate_domain: true,
182+
authorization_provider: None,
183+
}
184+
}
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
191+
#[test]
192+
fn test_authorization_provider_builder() {
193+
let token = "test-token-123".to_string();
194+
let provider = Arc::new(move || Some(format!("Bearer {}", token)));
195+
196+
let config = ConfigBuilder::new()
197+
.authorization_provider(Some(provider.clone()))
198+
.build();
199+
200+
assert!(config.authorization_provider().is_some());
201+
202+
// Test that the provider returns the expected value
203+
if let Some(auth_provider) = config.authorization_provider() {
204+
assert_eq!(auth_provider(), Some("Bearer test-token-123".to_string()));
147205
}
148206
}
207+
208+
#[test]
209+
fn test_authorization_provider_none() {
210+
let config = ConfigBuilder::new().build();
211+
212+
assert!(config.authorization_provider().is_none());
213+
}
214+
215+
#[test]
216+
fn test_authorization_provider_returns_none() {
217+
let provider = Arc::new(|| None);
218+
219+
let config = ConfigBuilder::new()
220+
.authorization_provider(Some(provider))
221+
.build();
222+
223+
assert!(config.authorization_provider().is_some());
224+
225+
// Test that the provider returns None
226+
if let Some(auth_provider) = config.authorization_provider() {
227+
assert_eq!(auth_provider(), None);
228+
}
229+
}
230+
231+
#[test]
232+
fn test_authorization_provider_dynamic_token() {
233+
use std::sync::RwLock;
234+
235+
// Simulate a token that can be updated
236+
let token = Arc::new(RwLock::new("initial-token".to_string()));
237+
let token_clone = token.clone();
238+
239+
let provider = Arc::new(move || Some(token_clone.read().unwrap().clone()));
240+
241+
let config = ConfigBuilder::new()
242+
.authorization_provider(Some(provider.clone()))
243+
.build();
244+
245+
// Initial token
246+
if let Some(auth_provider) = config.authorization_provider() {
247+
assert_eq!(auth_provider(), Some("initial-token".to_string()));
248+
}
249+
250+
// Update the token
251+
*token.write().unwrap() = "refreshed-token".to_string();
252+
253+
// Provider should return the new token
254+
if let Some(auth_provider) = config.authorization_provider() {
255+
assert_eq!(auth_provider(), Some("refreshed-token".to_string()));
256+
}
257+
}
258+
259+
#[test]
260+
fn test_config_debug_with_provider() {
261+
let provider = Arc::new(|| Some("secret-token".to_string()));
262+
263+
let config = ConfigBuilder::new()
264+
.authorization_provider(Some(provider))
265+
.build();
266+
267+
let debug_str = format!("{:?}", config);
268+
269+
// Should show <provider> instead of the actual function pointer
270+
assert!(debug_str.contains("<provider>"));
271+
// Should not leak the token value
272+
assert!(!debug_str.contains("secret-token"));
273+
}
274+
275+
#[test]
276+
fn test_config_debug_without_provider() {
277+
let config = ConfigBuilder::new().build();
278+
279+
let debug_str = format!("{:?}", config);
280+
281+
// Should show None for authorization_provider
282+
assert!(debug_str.contains("authorization_provider"));
283+
}
149284
}

0 commit comments

Comments
 (0)