Skip to content

Commit

Permalink
feat: support '.json' or inline JWKS secret for jwt decoding (#299)
Browse files Browse the repository at this point in the history
  • Loading branch information
vdbulcke authored Nov 24, 2023
1 parent 1143c29 commit b58b787
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 27 deletions.
124 changes: 97 additions & 27 deletions src/translators/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use crate::utils::{slurp_file, write_file, JWTError, JWTResult};
use base64::engine::general_purpose::STANDARD as base64_engine;
use base64::Engine as _;
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Header, TokenData, Validation};
use jsonwebtoken::{
decode, decode_header, jwk, Algorithm, DecodingKey, Header, TokenData, Validation,
};
use serde_derive::{Deserialize, Serialize};
use serde_json::to_string_pretty;
use std::collections::HashSet;
Expand Down Expand Up @@ -32,7 +34,11 @@ impl TokenOutput {
}
}

pub fn decoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResult<DecodingKey> {
pub fn decoding_key_from_secret(
alg: &Algorithm,
secret_string: &str,
header: Option<Header>,
) -> JWTResult<DecodingKey> {
match alg {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
if secret_string.starts_with('@') {
Expand All @@ -55,18 +61,19 @@ pub fn decoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResu
| Algorithm::PS384
| Algorithm::PS512 => {
if !&secret_string.starts_with('@') {
return Err(JWTError::Internal(format!(
"Secret for {alg:?} must be a file path starting with @",
)));
// allows to read JWKS from argument (e.g. output of 'curl https://auth.domain.com/jwks.json')
let secret = secret_string.as_bytes().to_vec();
return decoding_key_from_jwks_secret(&secret, header);
}

let secret = slurp_file(&secret_string.chars().skip(1).collect::<String>());

match secret_string.ends_with(".pem") {
true => {
DecodingKey::from_rsa_pem(&secret).map_err(jsonwebtoken::errors::Error::into)
}
false => Ok(DecodingKey::from_rsa_der(&secret)),
if secret_string.ends_with(".pem") {
DecodingKey::from_rsa_pem(&secret).map_err(jsonwebtoken::errors::Error::into)
} else if secret_string.ends_with(".json") {
return decoding_key_from_jwks_secret(&secret, header);
} else {
Ok(DecodingKey::from_rsa_der(&secret))
}
}
Algorithm::ES256 | Algorithm::ES384 => {
Expand All @@ -78,32 +85,85 @@ pub fn decoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResu

let secret = slurp_file(&secret_string.chars().skip(1).collect::<String>());

match secret_string.ends_with(".pem") {
true => {
DecodingKey::from_ec_pem(&secret).map_err(jsonwebtoken::errors::Error::into)
}
false => Ok(DecodingKey::from_ec_der(&secret)),
if secret_string.ends_with(".pem") {
DecodingKey::from_ec_pem(&secret).map_err(jsonwebtoken::errors::Error::into)
} else if secret_string.ends_with(".json") {
return decoding_key_from_jwks_secret(&secret, header);
} else {
Ok(DecodingKey::from_ec_der(&secret))
}
}
Algorithm::EdDSA => {
if !&secret_string.starts_with('@') {
return Err(JWTError::Internal(format!(
"Secret for {alg:?} must be a file path starting with @",
)));
// allows to read JWKS from argument (e.g. output of 'curl https://auth.domain.com/jwks.json')
let secret = secret_string.as_bytes().to_vec();
return decoding_key_from_jwks_secret(&secret, header);
}

let secret = slurp_file(&secret_string.chars().skip(1).collect::<String>());

match secret_string.ends_with(".pem") {
true => {
DecodingKey::from_ed_pem(&secret).map_err(jsonwebtoken::errors::Error::into)
}
false => Ok(DecodingKey::from_ed_der(&secret)),
if secret_string.ends_with(".pem") {
DecodingKey::from_ed_pem(&secret).map_err(jsonwebtoken::errors::Error::into)
} else if secret_string.ends_with(".json") {
return Err(JWTError::Internal(format!(
"JWKS secret not supported for {alg:?}"
)));
} else {
Ok(DecodingKey::from_ed_der(&secret))
}
}
}
}

pub fn decoding_key_from_jwks_secret(
secret: &[u8],
header: Option<Header>,
) -> JWTResult<DecodingKey> {
if let Some(h) = header {
return match parse_jwks(secret) {
Some(jwks) => decoding_key_from_jwks(jwks, &h),
None => Err(JWTError::Internal("Invalid jwks format".to_string())),
};
}
Err(JWTError::Internal("Invalid jwt header".to_string()))
}

pub fn decoding_key_from_jwks(jwks: jwk::JwkSet, header: &Header) -> JWTResult<DecodingKey> {
let kid = match &header.kid {
Some(k) => k.to_owned(),
None => {
return Err(JWTError::Internal(
"Missing 'kid' from jwt header".to_string(),
));
}
};

let j = match jwks.find(&kid) {
Some(j) => j,
None => {
return Err(JWTError::Internal(format!(
"No jwk found for 'kid' {kid:?}",
)));
}
};

match &j.algorithm {
jwk::AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e)
.map_err(jsonwebtoken::errors::Error::into),
jwk::AlgorithmParameters::EllipticCurve(ec) => {
DecodingKey::from_ec_components(&ec.x, &ec.y).map_err(jsonwebtoken::errors::Error::into)
}
_ => Err(JWTError::Internal("Unsupported alg".to_string())),
}
}

pub fn parse_jwks(secret: &[u8]) -> Option<jwk::JwkSet> {
match serde_json::from_slice(secret) {
Ok(jwks) => Some(jwks),
Err(_) => None,
}
}

pub fn decode_token(
arguments: &DecodeArgs,
) -> (
Expand All @@ -112,10 +172,6 @@ pub fn decode_token(
OutputFormat,
) {
let algorithm = translate_algorithm(&arguments.algorithm);
let secret = match arguments.secret.len() {
0 => None,
_ => Some(decoding_key_from_secret(&algorithm, &arguments.secret)),
};
let jwt = match arguments.jwt.as_str() {
"-" => {
let mut buffer = String::new();
Expand All @@ -131,6 +187,20 @@ pub fn decode_token(
.trim()
.to_owned();

let header = match decode_header(&jwt) {
Ok(header) => Some(header),
Err(_) => None,
};

let secret = match arguments.secret.len() {
0 => None,
_ => Some(decoding_key_from_secret(
&algorithm,
&arguments.secret,
header,
)),
};

let mut secret_validator = Validation::new(algorithm);

secret_validator.leeway = 1000;
Expand Down
101 changes: 101 additions & 0 deletions tests/main_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,67 @@ mod tests {
assert!(result.is_ok());
}

#[test]
fn encode_and_decodes_an_rsa_token_using_jwks_file() {
let body: String = "{\"field\":\"value\"}".to_string();
let encode_matcher = App::command()
.try_get_matches_from(vec![
"jwt",
"encode",
"-A",
"RS256",
"--kid",
"2caFcPx-aXaC6SevhV79UDIrs8LgUok2xo0A6DJPqJo",
"--exp",
"-S",
"@./tests/private_rsa_key.der",
&body,
])
.unwrap();
let encode_matches = encode_matcher.subcommand_matches("encode").unwrap();
let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap();
let encoded_token = encode_token(&encode_arguments).unwrap();
let decode_matcher = App::command()
.try_get_matches_from(vec![
"jwt",
"decode",
"-S",
"@./tests/pub_rsa_jwks.json",
"-A",
"RS256",
&encoded_token,
])
.unwrap();
let decode_matches = decode_matcher.subcommand_matches("decode").unwrap();
let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap();
let (result, _, _) = decode_token(&decode_arguments);

assert!(result.is_ok());
}

#[test]
fn decodes_an_rsa_ssa_pss_token_using_jwks_secret() {
let jwks = r#"{"keys":[{"use":"sig","kty":"RSA","kid":"2caFcPx-aXaC6SevhV79UDIrs8LgUok2xo0A6DJPqJo","n":"589r2P-JpeFPkH2T8-SBw7ttzHPPlVzqJwb_fcXJl8MGZ_7Jkt8k58Ukgp3cgRdChDNlnrFeXu1wSwU47Mf_o9bBLVQbNCJ7uL-vQYdFwzEipqHusywJ-Qm5qpJyWO5f2hXMHnomZ1KZW4isg7g1kvynUznlSwU25wNUvRurRImxigT2ohmZzHf37n51zyzci5JZxneOojcyfXdhDWtRGuSbREW3XZqKnJbUOK9HqosrgidbFZil3j2uf4br7DLtdlZMJ4JzTE_ZX273el_uv_XFg-OuHvgdBHtgzN9rkKapkPyUT0BsWfOPyjEtrjzdAAiFQfuwhwIWQPidzBUKtw","e":"AQAB"},{"use":"enc","kty":"RSA","kid":"2caFcPx-aXaC6SevhV79UDIrs8LgUok2xo0A6DJPqJo","n":"589r2P-JpeFPkH2T8-SBw7ttzHPPlVzqJwb_fcXJl8MGZ_7Jkt8k58Ukgp3cgRdChDNlnrFeXu1wSwU47Mf_o9bBLVQbNCJ7uL-vQYdFwzEipqHusywJ-Qm5qpJyWO5f2hXMHnomZ1KZW4isg7g1kvynUznlSwU25wNUvRurRImxigT2ohmZzHf37n51zyzci5JZxneOojcyfXdhDWtRGuSbREW3XZqKnJbUOK9HqosrgidbFZil3j2uf4br7DLtdlZMJ4JzTE_ZX273el_uv_XFg-OuHvgdBHtgzN9rkKapkPyUT0BsWfOPyjEtrjzdAAiFQfuwhwIWQPidzBUKtw","e":"AQAB"}]}"#;
let token: String = "eyJ0eXAiOiJKV1QiLCJraWQiOiIyY2FGY1B4LWFYYUM2U2V2aFY3OVVESXJzOExnVW9rMnhvMEE2REpQcUpvIiwiYWxnIjoiUFM1MTIifQ.eyJmaWVsZCI6InZhbHVlIiwiZm9vIjoiYmFyIn0.O6r-pK6rDw0BAadqJmBivtjk7ELU2pYpKIOU7qD8rah9mzwm29A0KoCoOabtQCkKNcmlcIKoC812UrP_nDZrAsC1msHPfjvkKlbkX63_zEcRCv-6VC1FMuek8yY6mhKiFaTISPDBfHCg_Fru2BDar_qBJn8rtct9y6cgDA5vLvL81jLmJrCXW8C5wP9xrkG5CUXdW9A8fqtxcEDoNZoYUoxCnLkh3Pz5IfAluepqDYjj6kvMWuAC88K1B_a1Z8QTqCuJZNIj_5g6UExmK7pqKvB5RZo62KGTw8wWqkmaPTf4TnD4n3Rb1K-MN1LTWMySqgPaw5YlSxT2eFwDvhRBnA".to_string();
let decode_matcher = App::command()
.try_get_matches_from(vec![
"jwt",
"decode",
"-S",
jwks,
"-A",
"PS512",
"--ignore-exp",
&token,
])
.unwrap();
let decode_matches = decode_matcher.subcommand_matches("decode").unwrap();
let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap();
let (result, _, _) = decode_token(&decode_arguments);

assert!(result.is_ok());
}

#[test]
fn returns_error_when_file_format_is_wrong_during_encode() {
let body: String = "{\"field\":\"value\"}".to_string();
Expand Down Expand Up @@ -693,6 +754,46 @@ mod tests {
assert!(result.is_ok());
}

#[test]
fn encodes_and_decodes_an_ecdsa_token_using_jwks_from_file() {
let body: String = "{\"field\":\"value\"}".to_string();
let encode_matcher = App::command()
.try_get_matches_from(vec![
"jwt",
"encode",
"-A",
"ES256",
"--kid",
"4h7wt2IHHu_RLR6OtlZjCe_mIt8xAReS0cDEwwWAeKU",
"--exp",
"-S",
"@./tests/private_ecdsa_key.pk8",
&body,
])
.unwrap();
let encode_matches = encode_matcher.subcommand_matches("encode").unwrap();
let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap();
let encoded_token = encode_token(&encode_arguments).unwrap();
let decode_matcher = App::command()
.try_get_matches_from(vec![
"jwt",
"decode",
"-S",
"@./tests/pub_ecdsa_jwks.json",
"-A",
"ES256",
&encoded_token,
])
.unwrap();
let decode_matches = decode_matcher.subcommand_matches("decode").unwrap();
let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap();
let (result, _, _) = decode_token(&decode_arguments);

dbg!(&result);

assert!(result.is_ok());
}

#[test]
fn encodes_and_decodes_an_eddsa_token_using_key_from_file() {
let body: String = "{\"field\":\"value\"}".to_string();
Expand Down
20 changes: 20 additions & 0 deletions tests/pub_ecdsa_jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"keys": [
{
"use": "sig",
"kty": "EC",
"kid": "4h7wt2IHHu_RLR6OtlZjCe_mIt8xAReS0cDEwwWAeKU",
"crv": "P-256",
"x": "w7JAoU_gJbZJvV-zCOvU9yFJq0FNC_edCMRM78P8eQQ",
"y": "wQg1EytcsEmGrM70Gb53oluoDbVhCZ3Uq3hHMslHVb4"
},
{
"use": "enc",
"kty": "EC",
"kid": "4h7wt2IHHu_RLR6OtlZjCe_mIt8xAReS0cDEwwWAeKU",
"crv": "P-256",
"x": "w7JAoU_gJbZJvV-zCOvU9yFJq0FNC_edCMRM78P8eQQ",
"y": "wQg1EytcsEmGrM70Gb53oluoDbVhCZ3Uq3hHMslHVb4"
}
]
}
18 changes: 18 additions & 0 deletions tests/pub_rsa_jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"keys": [
{
"use": "sig",
"kty": "RSA",
"kid": "2caFcPx-aXaC6SevhV79UDIrs8LgUok2xo0A6DJPqJo",
"n": "589r2P-JpeFPkH2T8-SBw7ttzHPPlVzqJwb_fcXJl8MGZ_7Jkt8k58Ukgp3cgRdChDNlnrFeXu1wSwU47Mf_o9bBLVQbNCJ7uL-vQYdFwzEipqHusywJ-Qm5qpJyWO5f2hXMHnomZ1KZW4isg7g1kvynUznlSwU25wNUvRurRImxigT2ohmZzHf37n51zyzci5JZxneOojcyfXdhDWtRGuSbREW3XZqKnJbUOK9HqosrgidbFZil3j2uf4br7DLtdlZMJ4JzTE_ZX273el_uv_XFg-OuHvgdBHtgzN9rkKapkPyUT0BsWfOPyjEtrjzdAAiFQfuwhwIWQPidzBUKtw",
"e": "AQAB"
},
{
"use": "enc",
"kty": "RSA",
"kid": "2caFcPx-aXaC6SevhV79UDIrs8LgUok2xo0A6DJPqJo",
"n": "589r2P-JpeFPkH2T8-SBw7ttzHPPlVzqJwb_fcXJl8MGZ_7Jkt8k58Ukgp3cgRdChDNlnrFeXu1wSwU47Mf_o9bBLVQbNCJ7uL-vQYdFwzEipqHusywJ-Qm5qpJyWO5f2hXMHnomZ1KZW4isg7g1kvynUznlSwU25wNUvRurRImxigT2ohmZzHf37n51zyzci5JZxneOojcyfXdhDWtRGuSbREW3XZqKnJbUOK9HqosrgidbFZil3j2uf4br7DLtdlZMJ4JzTE_ZX273el_uv_XFg-OuHvgdBHtgzN9rkKapkPyUT0BsWfOPyjEtrjzdAAiFQfuwhwIWQPidzBUKtw",
"e": "AQAB"
}
]
}

0 comments on commit b58b787

Please sign in to comment.