Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions examples/jwt_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! # 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<RwLock<...>>` 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());
//! ```
//!
//! ## 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<dyn Fn() -> Option<String> + 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.");
}
}
}
38 changes: 29 additions & 9 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, Error> {
let auth = config.authorization_provider().cloned();

#[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));
}
Expand All @@ -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)
}
Expand Down
137 changes: 136 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Fn() -> Option<String> + 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<Socks5Config>,
Expand All @@ -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<AuthProvider>,
}

// 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(|_| "<provider>"),
)
.finish()
}
}

/// Configuration for Socks5
Expand Down Expand Up @@ -72,6 +94,12 @@ impl ConfigBuilder {
self
}

/// Sets the authorization provider for dynamic token injection
pub fn authorization_provider(mut self, provider: Option<AuthProvider>) -> Self {
self.config.authorization_provider = provider;
self
}

/// Return the config and consume the builder
pub fn build(self) -> Config {
self.config
Expand Down Expand Up @@ -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<&AuthProvider> {
self.authorization_provider.as_ref()
}

/// Convenience method for calling [`ConfigBuilder::new`]
pub fn builder() -> ConfigBuilder {
ConfigBuilder::new()
Expand All @@ -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 <provider> instead of the actual function pointer
assert!(debug_str.contains("<provider>"));
// 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"));
}
}
Loading
Loading