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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ openssl = ["dep:openssl"]
use-rustls = ["rustls"]
use-rustls-ring = ["rustls-ring"]
use-openssl = ["openssl"]

# optional dependencies only used in `jwt_dynamic_auth` example
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
bitreq = { version = "0.3.4", features = ["async-https", "json-using-serde"] }
88 changes: 88 additions & 0 deletions examples/jwt_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! # JWT Static Authentication with Electrum Client
//!
//! This example demonstrates how to use a static JWT_TOKEN authentication with the
//! electrum-client library.

use bitcoin::Txid;
use electrum_client::{Client, ConfigBuilder, ElectrumApi};
use std::{str::FromStr, sync::Arc};

const ELECTRUM_URL: &str = "ssl://electrum.blockstream.info:50002";

const GENESIS_HEIGHT: usize = 0;
const GENESIS_TXID: &str = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b";

fn main() {
// A static JWT_TOKEN (i.e JWT_TOKEN="Bearer jwt_token...")
let auth_provider = Arc::new(move || {
let jwt_token = std::env::var("JWT_TOKEN").expect("JWT_TOKEN env variable not set");
Some(jwt_token)
});

// The Electrum Server URL (i.e `ELECTRUM_URL` environment variable, or defaults to `ELECTRUM_URL` const above)
let electrum_url = std::env::var("ELECTRUM_URL").unwrap_or(ELECTRUM_URL.to_owned());

// Builds the electrum-client `Config`.
let config = ConfigBuilder::new()
.validate_domain(false)
.authorization_provider(Some(auth_provider))
.build();

// Builds & Connect electrum-client `Client`.
match Client::from_config(&electrum_url, config) {
Ok(client) => {
println!(
"Successfully connected to Electrum Server: {:#?}; with JWT authentication!",
electrum_url
);

// try to call the `server.features` method, it can fail on some servers.
match client.server_features() {
Ok(features) => println!(
"Successfully fetched the `server.features`!\n{:#?}",
features
),
Err(e) => eprintln!("Failed to fetch the `server.features`!\nError: {:#?}", e),
}

// try to call the `blockchain.block.header` method, it should NOT fail.
let genesis_height = GENESIS_HEIGHT;
match client.block_header(genesis_height) {
Ok(header) => {
println!(
"Successfully fetched the `Header` for given `height`={}!\n{:#?}",
genesis_height, header
);
}
Err(err) => eprintln!(
"Failed to fetch the `Header` for given `height`!\nError: {:#?}",
err
),
}

// try to call the `blockchain.transaction.get` method, it should NOT fail.
let genesis_txid =
Txid::from_str(GENESIS_TXID).expect("SHOULD have a valid genesis `txid`");
match client.transaction_get(&genesis_txid) {
Ok(tx) => {
println!(
"Successfully fetched the `Transaction` for given `txid`={}!\n{:#?}",
genesis_txid, tx
);
}
Err(err) => eprintln!(
"Failed to fetch the `Transaction` for given `txid`!\nError: {:#?}",
err
),
}
}
Err(err) => {
eprintln!(
"Failed to build and connect `Client` to {:#?}!\nError: {:#?}\n",
electrum_url, err
);
eprintln!("NOTE: This example requires an Electrum Server that handles/accept JWT authentication!");
eprintln!("Try to update the `ELECTRUM_URL` and `JWT_TOKEN to match your setup.");
}
}
}
183 changes: 183 additions & 0 deletions examples/jwt_dynamic_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! # JWT Dynamic Authentication
//!
//! ## Advanced: Token Refresh with Keycloak
//!
//! 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.
//!
//! In order to have an automatic token refresh (e.g it expires every 5 minutes),
//! you should use a shared token holder (e.g KeycloakTokenManager)
//! behind an `Arc<RwLock<...>>` and spawn a background task to refresh it.
//!
//! ## 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.

use electrum_client::{Client, ConfigBuilder, ElectrumApi};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::time::sleep;

/// Manages JWT tokens from Keycloak with automatic refresh
struct KeycloakTokenManager {
token: Arc<RwLock<Option<String>>>,
keycloak_url: String,
grant_type: String,
client_id: String,
client_secret: String,
}

impl KeycloakTokenManager {
fn new(
keycloak_url: String,
grant_type: String,
client_id: String,
client_secret: String,
) -> Self {
Self {
token: Arc::new(RwLock::new(None)),
keycloak_url,
client_id,
client_secret,
grant_type,
}
}

/// Get the current token (for the auth provider)
fn get_token(&self) -> Option<String> {
self.token.read().unwrap().clone()
}

/// Fetch a fresh token from Keycloak
async fn fetch_token(&self) -> Result<String, Box<dyn std::error::Error>> {
let url = format!("{}/protocol/openid-connect/token", self.keycloak_url);

// if you're using other HTTP client (i.e `reqwest`), you can probably use `.form` methods.
// it's currently not implemented in `bitreq`, needs to be built manually.
let body = format!(
"grant_type={}&client_id={}&client_secret={}",
self.grant_type, self.client_id, self.client_secret
);

let response = bitreq::post(url)
.with_header("Content-Type", "application/x-www-form-urlencoded")
.with_body(body)
.send_async()
.await?;

let json: serde_json::Value = response.json()?;
let access_token = json["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();

Ok(format!("Bearer {}", access_token))
}

/// Background task that refreshes the token every 4 minutes
async fn refresh_loop(self: Arc<Self>) {
loop {
// Refresh every 4 minutes (tokens expire at 5 minutes)
sleep(Duration::from_secs(240)).await;

match self.fetch_token().await {
Ok(new_token) => {
println!("Token refreshed successfully");
// In a background thread/task, periodically update the token
*self.token.write().unwrap() = Some(new_token);
}
Err(e) => {
eprintln!("Failed to refresh token: {}", e);
// Keep using old token until we can refresh
}
}
}
}
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// The Electrum Server URL (i.e `ELECTRUM_URL` environment variable)
let electrum_url = std::env::var("ELECTRUM_URL")
.expect("SHOULD have the `ELECTRUM_URL` environment variable!");

// The JWT_TOKEN manager setup (i.e Keycloak server URL, client ID and secret)
let keycloak_url = std::env::var("KEYCLOAK_URL")
.expect("SHOULD have the `KEYCLOAK_URL` environment variable!");

let grant_type = std::env::var("GRANT_TYPE").unwrap_or("client_credentials".to_string());
let client_id =
std::env::var("CLIENT_ID").expect("SHOULD have the `CLIENT_ID` environment variable!");
let client_secret = std::env::var("CLIENT_SECRET")
.expect("SHOULD have the `CLIENT_SECRET` environment variable!");

// Setup `KeycloakTokenManager`
let token_manager = Arc::new(KeycloakTokenManager::new(
keycloak_url,
grant_type,
client_id,
client_secret,
));

// Fetch initial token
let jwt_token = token_manager.fetch_token().await?;

println!("JWT_TOKEN='{}'", &jwt_token[..jwt_token.len().min(40)]);

*token_manager.token.write().unwrap() = Some(jwt_token);

// Start background refresh task
let tm_clone = token_manager.clone();
tokio::spawn(async move {
tm_clone.refresh_loop().await;
});

// Create Electrum client with dynamic auth provider
let tm_for_provider = token_manager.clone();
let config = ConfigBuilder::new()
.authorization_provider(Some(Arc::new(move || tm_for_provider.get_token())))
.build();

let client = Client::from_config(&electrum_url, config)?;

// All RPC calls will automatically include fresh JWT tokens
loop {
match client.server_features() {
Ok(features) => println!("Connected: {:?}", features),
Err(e) => eprintln!("Error: {}", e),
}

tokio::time::sleep(Duration::from_secs(10)).await;
}
}
40 changes: 30 additions & 10 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,36 @@ 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_provider = 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() {
let raw_client = match config.socks5() {
Some(socks5) => RawClient::new_proxy_ssl(
url.as_str(),
config.validate_domain(),
socks5,
config.timeout(),
auth_provider,
)?,
None => RawClient::new_ssl(
url.as_str(),
config.validate_domain(),
config.timeout(),
auth_provider,
)?,
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())?;

return Ok(ClientType::SSL(client));
let raw_client = RawClient::new_ssl(
url.as_str(),
config.validate_domain(),
config.timeout(),
auth_provider,
)?;

return Ok(ClientType::SSL(raw_client));
}

#[cfg(not(any(feature = "openssl", feature = "rustls", feature = "rustls-ring")))]
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(
url.as_str(),
socks5,
config.timeout(),
auth_provider,
)?),
None => ClientType::TCP(RawClient::new(
url.as_str(),
config.timeout(),
auth_provider,
)?),
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(
url.as_str(),
config.timeout(),
auth_provider,
)?);

Ok(client)
}
Expand Down
Loading
Loading