Skip to content

Commit

Permalink
feat: Working test with WASM plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
rholshausen committed Jul 25, 2024
1 parent db7582c commit 394c78b
Show file tree
Hide file tree
Showing 9 changed files with 501 additions and 10 deletions.
94 changes: 91 additions & 3 deletions drivers/rust/driver/src/wasm_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};

use crate::catalogue_manager::{CatalogueEntryProviderType, CatalogueEntryType, register_plugin_entries};
use crate::mock_server::{MockServerConfig, MockServerDetails, MockServerResults};
use crate::plugin_models::{CompareContentRequest, CompareContentResult, GenerateContentRequest, PactPlugin, PactPluginManifest};
use crate::plugin_models::{
CompareContentRequest,
CompareContentResult,
GenerateContentRequest,
PactPlugin,
PactPluginManifest,
PluginInteractionConfig
};
use crate::verification::{InteractionVerificationData, InteractionVerificationResult};

bindgen!();
Expand Down Expand Up @@ -71,6 +78,78 @@ impl Into<pact_models::content_types::ContentTypeHint> for ContentTypeHint {
}
}

impl From<CompareContentRequest> for CompareContentsRequest {
fn from(value: CompareContentRequest) -> Self {
CompareContentsRequest {
expected: value.expected_contents.into(),
actual: value.actual_contents.into(),
allow_unexpected_keys: value.allow_unexpected_keys,
plugin_configuration: value.plugin_configuration
.map(|config| config.into())
.unwrap_or_else(|| PluginConfiguration {
interaction_configuration: Default::default(),
pact_configuration: Default::default()
})
}
}
}

impl From<OptionalBody> for Body {
fn from(value: OptionalBody) -> Self {
Body {
content: value.value().unwrap_or_default().to_vec(),
content_type: value.content_type().unwrap_or_default().to_string(),
content_type_hint: None
}
}
}

impl From<PluginInteractionConfig> for PluginConfiguration {
fn from(value: PluginInteractionConfig) -> Self {
PluginConfiguration {
pact_configuration: Value::Object(value.pact_configuration
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()).to_string(),
interaction_configuration: Value::Object(value.interaction_configuration
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()).to_string(),
}
}
}

impl Into<CompareContentResult> for CompareContentsResponse {
fn into(self) -> CompareContentResult {
if let Some(type_mismatch) = &self.type_mismatch {
CompareContentResult::TypeMismatch(type_mismatch.expected.clone(), type_mismatch.actual.clone())
} else if !self.results.is_empty() {
let mismatches = self.results
.iter()
.map(|(path, mismatches)| {
(path.clone(), mismatches.iter().map(|m| m.into()).collect())
})
.collect();
CompareContentResult::Mismatches(mismatches)
} else {
CompareContentResult::OK
}
}
}

impl From<&ContentMismatch> for crate::content::ContentMismatch {
fn from(value: &ContentMismatch) -> Self {
crate::content::ContentMismatch {
expected: "".to_string(),
actual: "".to_string(),
mismatch: value.mismatch.to_string(),
path: value.path.to_string(),
diff: None,
mismatch_type: None,
}
}
}

/// Plugin that executes in a WASM VM
#[derive(Clone)]
pub struct WasmPlugin {
Expand Down Expand Up @@ -137,8 +216,17 @@ impl PactPlugin for WasmPlugin {
todo!()
}

async fn match_contents(&self, request: CompareContentRequest) -> anyhow::Result<CompareContentResult> {
todo!()
async fn match_contents(
&self,
request: CompareContentRequest
) -> anyhow::Result<CompareContentResult> {
let mut store = self.store.lock().unwrap();

let result = self.instance.call_compare_contents(store.as_context_mut(), &request.into())?
.map_err(|err| anyhow!(err))?;
debug!("Result from call to compare_contents: {:?}", result);

Ok(result.into())
}

async fn configure_interaction(
Expand Down
50 changes: 50 additions & 0 deletions drivers/rust/driver/wit/plugin.wit
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,53 @@ world plugin {
plugin-config: option<plugin-configuration>
}

// Request to preform a comparison on an actual body given the expected one
record compare-contents-request {
// Expected body from the Pact interaction
expected: body,
// Actual received body
actual: body,
// If unexpected keys or attributes should be allowed. Setting this to false results in additional keys or fields
// will cause a mismatch
allow-unexpected-keys: bool,
// Map of expressions to matching rules. The expressions follow the documented Pact matching rule expressions
// map<string, MatchingRules> rules = 4;
// Additional data added to the Pact/Interaction by the plugin
plugin-configuration: plugin-configuration
}

// Indicates that there was a mismatch with the content type and the body was not compared
record content-type-mismatch {
// Expected content type (MIME format)
expected: string,
// Actual content type received (MIME format)
actual: string
}

// A mismatch for an particular item of content
record content-mismatch {
// Expected data bytes
expected: list<u8>,
// Actual data bytes
actual: list<u8>,
// Description of the mismatch
mismatch: string,
// Path to the item that was matched. This is the value as per the documented Pact matching rule expressions.
path: string,
// Optional diff of the contents
diff: option<string>,
// Part of the interaction that the mismatch is for: body, headers, metadata, etc.
mismatch-type: string
}

// Response to the CompareContentsRequest with the results of the comparison
record compare-contents-response {
// There was a mismatch with the types of content. If this is set, the results may not be set.
type-mismatch: option<content-type-mismatch>,
// Results of the match, keyed by matching rule expression
results: list<tuple<string, list<content-mismatch>>>
}

// Init function is called after the plugin is loaded. It needs to return the plugin catalog
// entries to be added to the global catalog
export init: func(implementation: string, version: string) -> list<catalogue-entry>;
Expand All @@ -129,4 +176,7 @@ world plugin {

// Request to configure/setup the interaction for later verification. Data returned will be persisted in the pact file.
export configure-interaction: func(content-type: string, config-json: string) -> result<interaction-details, string>;

// Request to perform a comparison of some contents (matching request)
export compare-contents: func(request: compare-contents-request) -> result<compare-contents-response, string>;
}
1 change: 1 addition & 0 deletions plugins/jwt/wasm-plugin/Cargo.lock

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

1 change: 1 addition & 0 deletions plugins/jwt/wasm-plugin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ crate-type = ["cdylib"]
[dependencies]
anyhow = "1.0.86"
base64 = "0.22.1"
maplit = "1.0.2"
pact_models = { version = "1.2.2", default-features = false }
serde_json = "1.0.120"
rsa = { version = "0.9.6", features = ["sha2"] }
Expand Down
Binary file modified plugins/jwt/wasm-plugin/jwt_plugin.wasm
Binary file not shown.
68 changes: 63 additions & 5 deletions plugins/jwt/wasm-plugin/src/jwt.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
use std::str::from_utf8;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::anyhow;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE as BASE64;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
use pact_models::generators::generate_hexadecimal;
use rsa::pkcs1::DecodeRsaPrivateKey;
use rsa::pkcs1v15::SigningKey;
use rsa::RsaPrivateKey;
use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey};
use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey};
use rsa::{RsaPrivateKey, RsaPublicKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::sha2::Sha512;
use rsa::signature::{SignatureEncoding, Signer};
use rsa::signature::{SignatureEncoding, Signer, Verifier};
use serde_json::{Map, Value};

use crate::json_to_string;
Expand Down Expand Up @@ -89,3 +91,59 @@ pub(crate) fn sign_token(
let encoded_signature = BASE64.encode(signature.to_bytes());
Ok(encoded_signature)
}

#[derive(Debug, Clone)]
pub struct Token {
pub header: Map<String, Value>,
pub claims: Map<String, Value>,
pub signature: String,
pub encoded: String,
}

pub(crate) fn decode_token(token_bytes: &[u8]) -> anyhow::Result<Token> {
let encoded_string = from_utf8(token_bytes)?;
log(format!("Encoded token = {}", encoded_string).as_str());
let parts: Vec<_> = encoded_string.split('.').collect();

let header_part = parts.get(0)
.ok_or_else(|| anyhow!("Token header was missing from token string"))?;
let header = BASE64.decode(header_part)
.map_err(|err| anyhow!("Failed to decode the token bytes: {}", err))
.and_then(|bytes| serde_json::from_slice::<Value>(bytes.as_slice())
.map_err(|err| anyhow!("Failed to parse token header as JSON: {}", err)))?;
log(format!("Token header = {}", header).as_str());

let claims_part = parts.get(1)
.ok_or_else(|| anyhow!("Token claims was missing from token string"))?;
let claims = BASE64.decode(claims_part)
.map_err(|err| anyhow!("Failed to decode the token bytes: {}", err))
.and_then(|bytes| serde_json::from_slice::<Value>(bytes.as_slice())
.map_err(|err| anyhow!("Failed to parse token claims as JSON: {}", err)))?;
log(format!("Token claims = {}", claims).as_str());

let signature = parts.get(1)
.ok_or_else(|| anyhow!("Token signature was missing from token string"))?;
log(format!("Token signature = {}", signature).as_str());

Ok(Token {
header: header.as_object().cloned().unwrap_or_default(),
claims: claims.as_object().cloned().unwrap_or_default(),
signature: signature.to_string(),
encoded: encoded_string.to_string()
})
}

pub(crate) fn validate_signature(token: &Token, algorithm: &String, public_key: &String) -> anyhow::Result<()> {
log(format!("Signature algorithm is set to {}", algorithm).as_str());
if algorithm != "RS512" {
return Err(anyhow!("Only the RS512 algorithm is supported at the moment"));
}

let public_key = RsaPublicKey::from_public_key_pem(public_key)?;
let verifying_key = VerifyingKey::<Sha512>::new(public_key);
let (base_token, sig) = token.encoded.rsplit_once('.')
.ok_or_else(|| anyhow!("Encoded token is not valid, was expecting parts seperated with a '.'"))?;
let signature = Signature::try_from(BASE64.decode(sig)?.as_slice())?;
verifying_key.verify(base_token.as_bytes(), &signature)
.map_err(|err| anyhow!("Failed to verify token signature: {}", err))
}
52 changes: 50 additions & 2 deletions plugins/jwt/wasm-plugin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use anyhow::anyhow;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE as BASE64;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
use rsa::pkcs1::{DecodeRsaPrivateKey, LineEnding};
use rsa::pkcs8::EncodePublicKey;
use rsa::RsaPrivateKey;
use serde_json::{json, Value};

mod jwt;
mod matching;

wit_bindgen::generate!();

Expand Down Expand Up @@ -77,7 +78,7 @@ impl Guest for JwtPlugin {
let plugin_config = PluginConfiguration {
interaction_configuration: json!({
"public-key": public_key,
"algorithm": format!("{}", header["alg"])
"algorithm": json_to_string(&header["alg"])
}).to_string(),
pact_configuration: "".to_string()
};
Expand All @@ -98,6 +99,53 @@ impl Guest for JwtPlugin {
plugin_config: Some(plugin_config)
})
}

// This function does the actual matching
fn compare_contents(request: CompareContentsRequest) -> Result<CompareContentsResponse, String> {
log(format!("Got a match request: {:?}", request).as_str());

let interaction_configuration: Value = serde_json::from_str(request.plugin_configuration.interaction_configuration.as_str())
.map_err(|err| format!("Failed to parse the plugin configuration: {}", err))?;
let public_key = json_to_string(&interaction_configuration.get("public-key")
.cloned()
.unwrap_or_default());
let algorithm = json_to_string(&interaction_configuration
.get("algorithm")
.cloned()
.unwrap_or_default());

let expected_jwt = jwt::decode_token(request.expected.content.as_slice())
.map_err(|err| format!("Failed to decode the expected token: {}", err))?;
log(format!("Expected JWT: {:?}", expected_jwt).as_str());

let actual_jwt = jwt::decode_token(request.actual.content.as_slice())
.map_err(|err| format!("Failed to decode the actual token: {}", err))?;
log(format!("Actual JWT: {:?}", actual_jwt).as_str());

let mut result = CompareContentsResponse {
type_mismatch: None,
results: vec![]
};

if let Err(token_issues) = matching::validate_token(&actual_jwt, &algorithm, &public_key) {
result.results.push(("$".to_string(), token_issues));
}

if let Err(header_mismatches) = matching::match_headers(&expected_jwt.header, &actual_jwt.header) {
for (k, v) in header_mismatches {
result.results.push((format!("header:{}", k), v));
}
}

if let Err(claim_mismatches) = matching::match_claims(&expected_jwt.claims, &actual_jwt.claims) {
for (k, v) in claim_mismatches {
result.results.push((format!("claims:{}", k), v));
}
}

log(format!("returning match result -> {:?}", result).as_str());
Ok(result)
}
}

fn rsa_public_key(private_key: &str) -> anyhow::Result<String> {
Expand Down
Loading

0 comments on commit 394c78b

Please sign in to comment.