diff --git a/ethers-providers/Cargo.toml b/ethers-providers/Cargo.toml index b25d45a6f..16b5cc58f 100644 --- a/ethers-providers/Cargo.toml +++ b/ethers-providers/Cargo.toml @@ -32,6 +32,7 @@ http = "0.2" reqwest = { workspace = true, features = ["json"] } url.workspace = true base64 = "0.21" +jsonwebtoken = "8" async-trait.workspace = true hex.workspace = true diff --git a/ethers-providers/src/rpc/transports/common.rs b/ethers-providers/src/rpc/transports/common.rs index 8a574613c..3c65dd969 100644 --- a/ethers-providers/src/rpc/transports/common.rs +++ b/ethers-providers/src/rpc/transports/common.rs @@ -5,6 +5,7 @@ use ethers_core::{ abi::AbiDecode, types::{Bytes, U256}, }; +use jsonwebtoken::{encode, errors::Error, get_current_timestamp, Algorithm, EncodingKey, Header}; use serde::{ de::{self, MapAccess, Unexpected, Visitor}, Deserialize, Serialize, @@ -270,6 +271,106 @@ impl fmt::Display for Authorization { } } +/// Default algorithm used for JWT token signing. +const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256; + +/// JWT secret length in bytes. +pub const JWT_SECRET_LENGTH: usize = 32; + +/// Generates a bearer token from a JWT secret +pub struct JwtKey([u8; JWT_SECRET_LENGTH]); + +impl JwtKey { + /// Wrap given slice in `Self`. Returns an error if slice.len() != `JWT_SECRET_LENGTH`. + pub fn from_slice(key: &[u8]) -> Result { + if key.len() != JWT_SECRET_LENGTH { + return Err(format!( + "Invalid key length. Expected {} got {}", + JWT_SECRET_LENGTH, + key.len() + )) + } + let mut res = [0; JWT_SECRET_LENGTH]; + res.copy_from_slice(key); + Ok(Self(res)) + } + + /// Decode the given string from hex (no 0x prefix), and attempt to create a key from it. + pub fn from_hex(hex: &str) -> Result { + let bytes = hex::decode(hex).map_err(|e| format!("Invalid hex: {}", e))?; + Self::from_slice(&bytes) + } + + /// Returns a reference to the underlying byte array. + pub fn as_bytes(&self) -> &[u8; JWT_SECRET_LENGTH] { + &self.0 + } + + /// Consumes the key, returning its underlying byte array. + pub fn into_bytes(self) -> [u8; JWT_SECRET_LENGTH] { + self.0 + } +} + +/// Contains the JWT secret and claims parameters. +pub struct JwtAuth { + key: EncodingKey, + id: Option, + clv: Option, +} + +impl JwtAuth { + /// Create a new [JwtAuth] from a secret key, and optional `id` and `clv` claims. + pub fn new(secret: JwtKey, id: Option, clv: Option) -> Self { + Self { key: EncodingKey::from_secret(secret.as_bytes()), id, clv } + } + + /// Generate a JWT token with `claims.iat` set to current time. + pub fn generate_token(&self) -> Result { + let claims = self.generate_claims_at_timestamp(); + self.generate_token_with_claims(&claims) + } + + /// Generate a JWT token with the given claims. + fn generate_token_with_claims(&self, claims: &Claims) -> Result { + let header = Header::new(DEFAULT_ALGORITHM); + encode(&header, claims, &self.key) + } + + /// Generate a `Claims` struct with `iat` set to current time + fn generate_claims_at_timestamp(&self) -> Claims { + Claims { iat: get_current_timestamp(), id: self.id.clone(), clv: self.clv.clone() } + } + + /// Validate a JWT token given the secret key and return the originally signed `TokenData`. + pub fn validate_token( + token: &str, + secret: &JwtKey, + ) -> Result, Error> { + let mut validation = jsonwebtoken::Validation::new(DEFAULT_ALGORITHM); + validation.validate_exp = false; + validation.required_spec_claims.remove("exp"); + + jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .map_err(Into::into) + } +} + +/// Claims struct as defined in +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct Claims { + /// issued-at claim. Represented as seconds passed since UNIX_EPOCH. + iat: u64, + /// Optional unique identifier for the CL node. + id: Option, + /// Optional client version for the CL node. + clv: Option, +} + #[cfg(test)] mod tests { use ethers_core::types::U64; @@ -343,4 +444,23 @@ mod tests { r#"{"id":300,"jsonrpc":"2.0","method":"method_name","params":1}"# ); } + + #[test] + fn test_roundtrip() { + let jwt_secret = [42; 32]; + let auth = JwtAuth::new( + JwtKey::from_slice(&jwt_secret).unwrap(), + Some("42".into()), + Some("Lighthouse".into()), + ); + let claims = auth.generate_claims_at_timestamp(); + let token = auth.generate_token_with_claims(&claims).unwrap(); + + assert_eq!( + JwtAuth::validate_token(&token, &JwtKey::from_slice(&jwt_secret).unwrap()) + .unwrap() + .claims, + claims + ); + } } diff --git a/ethers-providers/src/rpc/transports/mod.rs b/ethers-providers/src/rpc/transports/mod.rs index 690967d8b..669eb4255 100644 --- a/ethers-providers/src/rpc/transports/mod.rs +++ b/ethers-providers/src/rpc/transports/mod.rs @@ -1,5 +1,5 @@ pub(crate) mod common; -pub use common::{Authorization, JsonRpcError}; +pub use common::{Authorization, JsonRpcError, JwtAuth, JwtKey}; mod http; pub use self::http::{ClientError as HttpClientError, Provider as Http}; diff --git a/examples/providers/examples/http_jwt.rs b/examples/providers/examples/http_jwt.rs new file mode 100644 index 000000000..650cc890a --- /dev/null +++ b/examples/providers/examples/http_jwt.rs @@ -0,0 +1,30 @@ +use ethers::prelude::*; + +const RPC_URL: &str = "http://localhost:8551"; + +#[tokio::main] +async fn main() -> eyre::Result<()> { + connect_jwt().await?; + Ok(()) +} + +async fn connect_jwt() -> eyre::Result<()> { + // An Http provider can be created from an http(s) URI. + // In case of https you must add the "rustls" or "openssl" feature + // to the ethers library dependency in `Cargo.toml`. + let _provider = Provider::::try_from(RPC_URL)?; + + // Instantiate with auth to append basic authorization headers across requests + let url = reqwest::Url::parse(RPC_URL)?; + + // Use a JWT signing key to generate a bearer token + let jwt_secret = &[42; 32]; + let secret = JwtKey::from_slice(jwt_secret).map_err(|err| eyre::eyre!("Invalid key: {err}"))?; + let jwt_auth = JwtAuth::new(secret, None, None); + let token = jwt_auth.generate_token()?; + + let auth = Authorization::bearer(token); + let _provider = Http::new_with_auth(url, auth)?; + + Ok(()) +}