Skip to content
Open
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
3 changes: 3 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions rust/auth-impls/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ edition = "2021"
rust-version.workspace = true

[features]
jwt = [ "jsonwebtoken", "serde" ]
jwt = [ "base64", "serde", "serde_json", "openssl" ]
sigs = [ "bitcoin_hashes", "hex-conservative", "secp256k1" ]

[dependencies]
async-trait = "0.1.77"
api = { path = "../api" }
jsonwebtoken = { version = "9.3.0", optional = true, default-features = false, features = ["use_pem"] }
serde = { version = "1.0.210", optional = true, default-features = false, features = ["derive"] }

async-trait = "0.1.77"
base64 = { version = "0.22.1", optional = true, default-features = false, features = ["std"] }
bitcoin_hashes = { version = "0.19", optional = true, default-features = false }
hex-conservative = { version = "1.0", optional = true, default-features = false }
openssl = { version = "0.10.75", optional = true, default-features = false }
secp256k1 = { version = "0.31", optional = true, default-features = false, features = [ "global-context" ] }
serde = { version = "1.0.210", optional = true, default-features = false, features = ["derive"] }
serde_json = { version = "1.0.149", optional = true, default-features = false, features = ["std"] }

[dev-dependencies]
jsonwebtoken = { version = "9.3.0", default-features = false, features = ["use_pem"] }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we already keep it as a dev dependency, can we add some parity/backwards compat tests that ensure our new custom implementation behaves the same way as this did?

tokio = { version = "1.38.0", default-features = false, features = ["rt-multi-thread", "macros"] }
70 changes: 61 additions & 9 deletions rust/auth-impls/src/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@
use api::auth::{AuthResponse, Authorizer};
use api::error::VssError;
use async_trait::async_trait;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
use openssl::hash::MessageDigest;
use openssl::pkey::PKey;
use openssl::pkey::Public;
use openssl::sign::Verifier;
use serde::Deserialize;
use std::collections::HashMap;

/// A JWT based authorizer, only allows requests with verified 'JsonWebToken' signed by the given
/// issuer key.
///
/// Refer: https://datatracker.ietf.org/doc/html/rfc7519
pub struct JWTAuthorizer {
jwt_issuer_key: DecodingKey,
jwt_issuer_key: PKey<Public>,
}

/// A set of Claims claimed by 'JsonWebToken'
///
/// Refer: https://datatracker.ietf.org/doc/html/rfc7519#section-4
#[derive(Serialize, Deserialize, Debug)]
#[derive(Deserialize, Debug)]
pub(crate) struct Claims {
/// The "sub" (subject) claim identifies the principal that is the subject of the JWT.
/// The claims in a JWT are statements about the subject. This can be used as user identifier.
Expand All @@ -31,10 +36,22 @@ pub(crate) struct Claims {

const BEARER_PREFIX: &str = "Bearer ";

fn parse_public_key_pem(pem: &str) -> Result<PKey<Public>, String> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we just return () or, if you prefer, a proper enum error type here? Using a string introduces some unnecessary allocations, and they can't easily be handled. Same goes for new then ofc.

let body = pem
.trim()
.strip_prefix("-----BEGIN PUBLIC KEY-----")
.ok_or(String::from("Prefix not found"))?
.strip_suffix("-----END PUBLIC KEY-----")
.ok_or(String::from("Suffix not found"))?;
let body: String = body.lines().map(|line| line.trim()).collect();
let body = STANDARD.decode(body).map_err(|_| String::from("Base64 decode failed"))?;
PKey::public_key_from_der(&body).map_err(|_| String::from("DER decode failed"))
}

impl JWTAuthorizer {
/// Creates a new instance of [`JWTAuthorizer`], fails on failure to parse the PEM formatted RSA public key
pub async fn new(rsa_pem: &str) -> Result<Self, String> {
let jwt_issuer_key = DecodingKey::from_rsa_pem(rsa_pem.as_bytes())
let jwt_issuer_key = parse_public_key_pem(rsa_pem)
.map_err(|e| format!("Failed to parse the PEM formatted RSA public key: {}", e))?;
Ok(Self { jwt_issuer_key })
}
Expand All @@ -53,10 +70,45 @@ impl Authorizer for JWTAuthorizer {
.strip_prefix(BEARER_PREFIX)
.ok_or(VssError::AuthError("Invalid token format.".to_string()))?;

let claims =
decode::<Claims>(token, &self.jwt_issuer_key, &Validation::new(Algorithm::RS256))
.map_err(|e| VssError::AuthError(format!("Authentication failure. {}", e)))?
.claims;
let mut iter = token.split('.');
let [header_base64, claims_base64, signature_base64] =
match [iter.next(), iter.next(), iter.next(), iter.next()] {
[Some(h), Some(c), Some(s), None] => [h, c, s],
_ => {
return Err(VssError::AuthError(String::from(
"Token does not have three parts",
)))
},
};

let header_bytes = URL_SAFE_NO_PAD
.decode(header_base64)
.map_err(|_| VssError::AuthError(String::from("Header base64 decode failed")))?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|_| VssError::AuthError(String::from("Header json decode failed")))?;
match header["alg"] {
serde_json::Value::String(ref alg) if alg == "RS256" => (),
_ => return Err(VssError::AuthError(String::from("alg: RS256 not found in header"))),
}

let (message, _) = token.rsplit_once('.').expect("There are two periods in the token");
let signature = URL_SAFE_NO_PAD
.decode(signature_base64)
.map_err(|_| VssError::AuthError(String::from("Signature base64 decode failed")))?;
let mut verifier = Verifier::new(MessageDigest::sha256(), &self.jwt_issuer_key)
.map_err(|_| VssError::AuthError(String::from("RSA initialization failed")))?;
if !verifier
.verify_oneshot(&signature, message.as_bytes())
.map_err(|_| VssError::AuthError(String::from("RSA verification failed")))?
{
return Err(VssError::AuthError(String::from("RSA verification failed")));
}

let claims_json = URL_SAFE_NO_PAD
.decode(claims_base64)
.map_err(|_| VssError::AuthError(String::from("Claims base64 decode failed")))?;
let claims: Claims = serde_json::from_slice(&claims_json)
.map_err(|_| VssError::AuthError(String::from("Claims json decode failed")))?;

Ok(AuthResponse { user_token: claims.sub })
}
Expand Down
2 changes: 1 addition & 1 deletion rust/auth-impls/src/signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ mod tests {
use crate::signature::{SignatureValidatingAuthorizer, SIGNING_CONSTANT};
use api::auth::Authorizer;
use api::error::VssError;
use secp256k1::{Message, PublicKey, Secp256k1, SecretKey};
use secp256k1::{Message, PublicKey, SecretKey};
use std::collections::HashMap;
use std::fmt::Write;
use std::time::SystemTime;
Expand Down