Skip to content

Commit ca27c4a

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

File tree

6 files changed

+666
-27
lines changed

6 files changed

+666
-27
lines changed

examples/JWT_AUTH_EXAMPLE.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# JWT Authentication with Electrum Client
2+
3+
This guide demonstrates how to use dynamic JWT authentication with the electrum-client library.
4+
5+
## Overview
6+
7+
The electrum-client now supports embedding authorization tokens (such as JWT Bearer tokens) directly in JSON-RPC requests. This is achieved through an `AuthProvider` callback that is invoked before each request.
8+
9+
## Basic Usage
10+
11+
```rust
12+
use electrum_client::{Client, ConfigBuilder};
13+
use std::sync::{Arc, RwLock};
14+
15+
// Simple example: Static token
16+
fn main() -> Result<(), Box<dyn std::error::Error>> {
17+
let token = "your-jwt-token-here".to_string();
18+
19+
let config = ConfigBuilder::new()
20+
.authorization_provider(Some(Arc::new(move || {
21+
Some(format!("Bearer {}", token))
22+
})))
23+
.build();
24+
25+
let client = Client::from_config("tcp://your-server:50001", config)?;
26+
27+
// All RPC calls will now include: "authorization": "Bearer your-jwt-token-here"
28+
let features = client.server_features()?;
29+
println!("{:?}", features);
30+
31+
Ok(())
32+
}
33+
```
34+
35+
## Advanced: Token Refresh with Keycloak
36+
37+
This example demonstrates automatic token refresh every 4 minutes (before the 5-minute expiration).
38+
39+
```rust
40+
use electrum_client::{Client, ConfigBuilder, ElectrumApi};
41+
use std::sync::{Arc, RwLock};
42+
use std::time::Duration;
43+
use tokio::time::sleep;
44+
45+
/// Manages JWT tokens from Keycloak with automatic refresh
46+
struct KeycloakTokenManager {
47+
token: Arc<RwLock<Option<String>>>,
48+
keycloak_url: String,
49+
client_id: String,
50+
client_secret: String,
51+
}
52+
53+
impl KeycloakTokenManager {
54+
fn new(keycloak_url: String, client_id: String, client_secret: String) -> Self {
55+
Self {
56+
token: Arc::new(RwLock::new(None)),
57+
keycloak_url,
58+
client_id,
59+
client_secret,
60+
}
61+
}
62+
63+
/// Get the current token (for the auth provider)
64+
fn get_token(&self) -> Option<String> {
65+
self.token.read().unwrap().clone()
66+
}
67+
68+
/// Fetch a fresh token from Keycloak
69+
async fn fetch_token(&self) -> Result<String, Box<dyn std::error::Error>> {
70+
// Example using reqwest to get JWT from Keycloak
71+
let client = reqwest::Client::new();
72+
let response = client
73+
.post(&format!("{}/protocol/openid-connect/token", self.keycloak_url))
74+
.form(&[
75+
("grant_type", "client_credentials"),
76+
("client_id", &self.client_id),
77+
("client_secret", &self.client_secret),
78+
])
79+
.send()
80+
.await?;
81+
82+
let json: serde_json::Value = response.json().await?;
83+
let access_token = json["access_token"]
84+
.as_str()
85+
.ok_or("Missing access_token")?
86+
.to_string();
87+
88+
Ok(format!("Bearer {}", access_token))
89+
}
90+
91+
/// Background task that refreshes the token every 4 minutes
92+
async fn refresh_loop(self: Arc<Self>) {
93+
loop {
94+
// Refresh every 4 minutes (tokens expire at 5 minutes)
95+
sleep(Duration::from_secs(240)).await;
96+
97+
match self.fetch_token().await {
98+
Ok(new_token) => {
99+
println!("Token refreshed successfully");
100+
*self.token.write().unwrap() = Some(new_token);
101+
}
102+
Err(e) => {
103+
eprintln!("Failed to refresh token: {}", e);
104+
// Keep using old token until we can refresh
105+
}
106+
}
107+
}
108+
}
109+
}
110+
111+
#[tokio::main]
112+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
113+
// Setup token manager
114+
let token_manager = Arc::new(KeycloakTokenManager::new(
115+
"https://your-keycloak-server/auth/realms/your-realm".to_string(),
116+
"your-client-id".to_string(),
117+
"your-client-secret".to_string(),
118+
));
119+
120+
// Fetch initial token
121+
let initial_token = token_manager.fetch_token().await?;
122+
*token_manager.token.write().unwrap() = Some(initial_token);
123+
124+
// Start background refresh task
125+
let tm_clone = token_manager.clone();
126+
tokio::spawn(async move {
127+
tm_clone.refresh_loop().await;
128+
});
129+
130+
// Create Electrum client with dynamic auth provider
131+
let tm_for_provider = token_manager.clone();
132+
let config = ConfigBuilder::new()
133+
.authorization_provider(Some(Arc::new(move || {
134+
tm_for_provider.get_token()
135+
})))
136+
.build();
137+
138+
let client = Client::from_config("tcp://your-api-gateway:50001", config)?;
139+
140+
// All RPC calls will automatically include fresh JWT tokens
141+
loop {
142+
match client.server_features() {
143+
Ok(features) => println!("Connected: {:?}", features),
144+
Err(e) => eprintln!("Error: {}", e),
145+
}
146+
147+
tokio::time::sleep(Duration::from_secs(10)).await;
148+
}
149+
}
150+
```
151+
152+
## Integration with BDK
153+
154+
To use with BDK, create the electrum client with your config, then wrap it:
155+
156+
```rust
157+
use bdk_electrum::BdkElectrumClient;
158+
use electrum_client::{Client, ConfigBuilder};
159+
use std::sync::Arc;
160+
use std::time::Duration;
161+
162+
let config = ConfigBuilder::new()
163+
.authorization_provider(Some(Arc::new(move || {
164+
token_manager.get_token()
165+
})))
166+
.timeout(Some(Duration::from_secs(30)))
167+
.build();
168+
169+
let electrum_client = Client::from_config("tcp://your-api-gateway:50001", config)?;
170+
let bdk_client = BdkElectrumClient::new(electrum_client);
171+
```
172+
173+
## JSON-RPC Request Format
174+
175+
With the auth provider configured, each JSON-RPC request will include the authorization field:
176+
177+
```json
178+
{
179+
"jsonrpc": "2.0",
180+
"method": "blockchain.headers.subscribe",
181+
"params": [],
182+
"id": 1,
183+
"authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
184+
}
185+
```
186+
187+
If the provider returns `None`, the authorization field is omitted from the request.
188+
189+
## Thread Safety
190+
191+
The `AuthProvider` type is defined as:
192+
```rust
193+
pub type AuthProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
194+
```
195+
196+
This ensures thread-safe access to tokens across all RPC calls.

examples/jwt_auth.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
extern crate electrum_client;
2+
3+
use electrum_client::{Client, ConfigBuilder, ElectrumApi};
4+
use std::sync::Arc;
5+
6+
fn main() {
7+
// Example 1: Static JWT token
8+
println!("Example 1: Static JWT token");
9+
10+
let config = ConfigBuilder::new()
11+
.authorization_provider(Some(Arc::new(|| {
12+
// In production, fetch this from your token manager
13+
Some("Bearer example-jwt-token-12345".to_string())
14+
})))
15+
.build();
16+
17+
match Client::from_config("tcp://localhost:50001", config) {
18+
Ok(client) => {
19+
println!("Connected to server with JWT auth");
20+
match client.server_features() {
21+
Ok(features) => println!("Server features: {:#?}", features),
22+
Err(e) => eprintln!("Error fetching features: {}", e),
23+
}
24+
}
25+
Err(e) => {
26+
eprintln!("Connection error: {}", e);
27+
eprintln!("\nNote: This example requires an Electrum server that accepts JWT auth.");
28+
eprintln!("Update the URL and token to match your setup.");
29+
}
30+
}
31+
32+
// Example 2: Dynamic token with refresh
33+
// See JWT_AUTH_EXAMPLE.md for complete implementation with automatic token refresh
34+
}

src/client.rs

Lines changed: 26 additions & 13 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,21 @@ 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(
149-
url.as_str(),
150-
socks5,
151-
config.timeout(),
152-
)?),
153-
None => ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?),
159+
Some(socks5) => ClientType::Socks5(
160+
RawClient::new_proxy_with_auth(url.as_str(), socks5, config.timeout(), auth)?,
161+
),
162+
None => ClientType::TCP(
163+
RawClient::new_with_auth(url.as_str(), config.timeout(), auth)?,
164+
),
154165
};
155166

156167
#[cfg(not(feature = "proxy"))]
157-
let client = ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?);
168+
let client = ClientType::TCP(
169+
RawClient::new_with_auth(url.as_str(), config.timeout(), auth)?,
170+
);
158171

159172
Ok(client)
160173
}

0 commit comments

Comments
 (0)