|
| 1 | +//! # JWT Dynamic Authentication |
| 2 | +//! |
| 3 | +//! ## Advanced: Token Refresh with Keycloak |
| 4 | +//! |
| 5 | +//! This example demonstrates how to use dynamic JWT authentication with the |
| 6 | +//! electrum-client library. |
| 7 | +//! |
| 8 | +//! ## Overview |
| 9 | +//! |
| 10 | +//! The electrum-client supports embedding authorization tokens (such as JWT |
| 11 | +//! Bearer tokens) directly in JSON-RPC requests. This is achieved through an |
| 12 | +//! [`AuthProvider`](electrum_client::config::AuthProvider) callback that is |
| 13 | +//! invoked before each request. |
| 14 | +//! |
| 15 | +//! In order to have an automatic token refresh (e.g it expires every 5 minutes), |
| 16 | +//! you should use a shared token holder (e.g KeycloakTokenManager) |
| 17 | +//! behind an `Arc<RwLock<...>>` and spawn a background task to refresh it. |
| 18 | +//! |
| 19 | +//! ## JSON-RPC Request Format |
| 20 | +//! |
| 21 | +//! With the auth provider configured, each JSON-RPC request will include the |
| 22 | +//! authorization field: |
| 23 | +//! |
| 24 | +//! ```json |
| 25 | +//! { |
| 26 | +//! "jsonrpc": "2.0", |
| 27 | +//! "method": "blockchain.headers.subscribe", |
| 28 | +//! "params": [], |
| 29 | +//! "id": 1, |
| 30 | +//! "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." |
| 31 | +//! } |
| 32 | +//! ``` |
| 33 | +//! |
| 34 | +//! If the provider returns `None`, the authorization field is omitted from the |
| 35 | +//! request. |
| 36 | +//! |
| 37 | +//! ## Thread Safety |
| 38 | +//! |
| 39 | +//! The `AuthProvider` type is defined as: |
| 40 | +//! |
| 41 | +//! ```rust,ignore |
| 42 | +//! pub type AuthProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>; |
| 43 | +//! ``` |
| 44 | +//! |
| 45 | +//! This ensures thread-safe access to tokens across all RPC calls. |
| 46 | +
|
| 47 | +use electrum_client::{Client, ConfigBuilder, ElectrumApi}; |
| 48 | +use std::sync::{Arc, RwLock}; |
| 49 | +use std::time::Duration; |
| 50 | +use tokio::time::sleep; |
| 51 | + |
| 52 | +/// Manages JWT tokens from Keycloak with automatic refresh |
| 53 | +struct KeycloakTokenManager { |
| 54 | + token: Arc<RwLock<Option<String>>>, |
| 55 | + keycloak_url: String, |
| 56 | + grant_type: String, |
| 57 | + client_id: String, |
| 58 | + client_secret: String, |
| 59 | +} |
| 60 | + |
| 61 | +impl KeycloakTokenManager { |
| 62 | + fn new( |
| 63 | + keycloak_url: String, |
| 64 | + grant_type: String, |
| 65 | + client_id: String, |
| 66 | + client_secret: String, |
| 67 | + ) -> Self { |
| 68 | + Self { |
| 69 | + token: Arc::new(RwLock::new(None)), |
| 70 | + keycloak_url, |
| 71 | + client_id, |
| 72 | + client_secret, |
| 73 | + grant_type, |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + /// Get the current token (for the auth provider) |
| 78 | + fn get_token(&self) -> Option<String> { |
| 79 | + self.token.read().unwrap().clone() |
| 80 | + } |
| 81 | + |
| 82 | + /// Fetch a fresh token from Keycloak |
| 83 | + async fn fetch_token(&self) -> Result<String, Box<dyn std::error::Error>> { |
| 84 | + let url = format!("{}/protocol/openid-connect/token", self.keycloak_url); |
| 85 | + |
| 86 | + // if you're using other HTTP client (i.e `reqwest`), you can probably use `.form` methods. |
| 87 | + // it's currently not implemented in `bitreq`, needs to be built manually. |
| 88 | + let body = format!( |
| 89 | + "grant_type={}&client_id={}&client_secret={}", |
| 90 | + self.grant_type, self.client_id, self.client_secret |
| 91 | + ); |
| 92 | + |
| 93 | + let response = bitreq::post(url) |
| 94 | + .with_header("Content-Type", "application/x-www-form-urlencoded") |
| 95 | + .with_body(body) |
| 96 | + .send_async() |
| 97 | + .await?; |
| 98 | + |
| 99 | + let json: serde_json::Value = response.json()?; |
| 100 | + let access_token = json["access_token"] |
| 101 | + .as_str() |
| 102 | + .ok_or("Missing access_token")? |
| 103 | + .to_string(); |
| 104 | + |
| 105 | + Ok(format!("Bearer {}", access_token)) |
| 106 | + } |
| 107 | + |
| 108 | + /// Background task that refreshes the token every 4 minutes |
| 109 | + async fn refresh_loop(self: Arc<Self>) { |
| 110 | + loop { |
| 111 | + // Refresh every 4 minutes (tokens expire at 5 minutes) |
| 112 | + sleep(Duration::from_secs(240)).await; |
| 113 | + |
| 114 | + match self.fetch_token().await { |
| 115 | + Ok(new_token) => { |
| 116 | + println!("Token refreshed successfully"); |
| 117 | + // In a background thread/task, periodically update the token |
| 118 | + *self.token.write().unwrap() = Some(new_token); |
| 119 | + } |
| 120 | + Err(e) => { |
| 121 | + eprintln!("Failed to refresh token: {}", e); |
| 122 | + // Keep using old token until we can refresh |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +#[tokio::main] |
| 130 | +async fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 131 | + // The Electrum Server URL (i.e `ELECTRUM_URL` environment variable) |
| 132 | + let electrum_url = std::env::var("ELECTRUM_URL") |
| 133 | + .expect("SHOULD have the `ELECTRUM_URL` environment variable!"); |
| 134 | + |
| 135 | + // The JWT_TOKEN manager setup (i.e Keycloak server URL, client ID and secret) |
| 136 | + let keycloak_url = std::env::var("KEYCLOAK_URL") |
| 137 | + .expect("SHOULD have the `KEYCLOAK_URL` environment variable!"); |
| 138 | + |
| 139 | + let grant_type = std::env::var("GRANT_TYPE").unwrap_or("client_credentials".to_string()); |
| 140 | + let client_id = |
| 141 | + std::env::var("CLIENT_ID").expect("SHOULD have the `CLIENT_ID` environment variable!"); |
| 142 | + let client_secret = std::env::var("CLIENT_SECRET") |
| 143 | + .expect("SHOULD have the `CLIENT_SECRET` environment variable!"); |
| 144 | + |
| 145 | + // Setup `KeycloakTokenManager` |
| 146 | + let token_manager = Arc::new(KeycloakTokenManager::new( |
| 147 | + keycloak_url, |
| 148 | + grant_type, |
| 149 | + client_id, |
| 150 | + client_secret, |
| 151 | + )); |
| 152 | + |
| 153 | + // Fetch initial token |
| 154 | + let jwt_token = token_manager.fetch_token().await?; |
| 155 | + |
| 156 | + println!("JWT_TOKEN='{}'", &jwt_token); |
| 157 | + |
| 158 | + *token_manager.token.write().unwrap() = Some(jwt_token); |
| 159 | + |
| 160 | + // Start background refresh task |
| 161 | + let tm_clone = token_manager.clone(); |
| 162 | + tokio::spawn(async move { |
| 163 | + tm_clone.refresh_loop().await; |
| 164 | + }); |
| 165 | + |
| 166 | + // Create Electrum client with dynamic auth provider |
| 167 | + let tm_for_provider = token_manager.clone(); |
| 168 | + let config = ConfigBuilder::new() |
| 169 | + .authorization_provider(Some(Arc::new(move || tm_for_provider.get_token()))) |
| 170 | + .build(); |
| 171 | + |
| 172 | + let client = Client::from_config(&electrum_url, config)?; |
| 173 | + |
| 174 | + // All RPC calls will automatically include fresh JWT tokens |
| 175 | + loop { |
| 176 | + match client.server_features() { |
| 177 | + Ok(features) => println!("Connected: {:?}", features), |
| 178 | + Err(e) => eprintln!("Error: {}", e), |
| 179 | + } |
| 180 | + |
| 181 | + tokio::time::sleep(Duration::from_secs(10)).await; |
| 182 | + } |
| 183 | +} |
0 commit comments