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 2 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
115 changes: 115 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,101 @@ 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] {
&self.0
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a way to get the underlying bytes back

Suggested change
/// Returns a reference to the underlying byte array.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Returns a reference to the underlying byte array.
pub fn as_bytes(&self) -> &[u8; JWT_SECRET_LENGTH] {
&self.0
}
pub fn into_bytes(self) ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, added

}

/// 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 +439,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(())
}