Skip to content
This repository has been archived by the owner on Oct 19, 2024. It is now read-only.

feat: add engine api-compatible bearer token generation #2529

Merged
merged 3 commits into from
Aug 2, 2023
Merged
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
1 change: 1 addition & 0 deletions ethers-providers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions ethers-providers/src/rpc/transports/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Self, String> {
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<Self, String> {
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<String>,
clv: Option<String>,
}

impl JwtAuth {
/// Create a new [JwtAuth] from a secret key, and optional `id` and `clv` claims.
pub fn new(secret: JwtKey, id: Option<String>, clv: Option<String>) -> 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<String, Error> {
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<String, Error> {
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<jsonwebtoken::TokenData<Claims>, Error> {
let mut validation = jsonwebtoken::Validation::new(DEFAULT_ALGORITHM);
validation.validate_exp = false;
validation.required_spec_claims.remove("exp");

jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map_err(Into::into)
}
}

/// Claims struct as defined in <https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md#jwt-claims>
#[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<String>,
/// Optional client version for the CL node.
clv: Option<String>,
}

#[cfg(test)]
mod tests {
use ethers_core::types::U64;
Expand Down Expand Up @@ -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
);
}
}
2 changes: 1 addition & 1 deletion ethers-providers/src/rpc/transports/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down
30 changes: 30 additions & 0 deletions examples/providers/examples/http_jwt.rs
Original file line number Diff line number Diff line change
@@ -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::<Http>::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(())
}