Skip to content
Open
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 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test = true
[dependencies]
clap = { version = "4", features = ["derive"] }
jsonwebtoken = "9.3.1"
base64 = "0.22"
bunt = "0.2"
serde = "1"
serde_derive = "1"
Expand Down
35 changes: 20 additions & 15 deletions src/cli_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,11 @@ pub struct EncodeArgs {
#[clap(value_parser)]
pub no_typ: bool,

/// the secret to sign the JWT with. Prefix with @ to read from a file or b64: to use base-64 encoded bytes
/// the secret to sign the JWT with. Prefix with @ to read from a file or b64: to use base-64 encoded bytes.
/// Not required when using --alg none.
#[clap(long, short = 'S')]
#[clap(value_parser)]
pub secret: String,
pub secret: Option<String>,

/// The path of the file to write the result to (suppresses default standard output)
#[clap(long = "out", short = 'o')]
Expand Down Expand Up @@ -194,6 +195,9 @@ pub enum SupportedAlgorithms {
ES256,
ES384,
EdDSA,
/// Unsecured JWT (RFC 7519 Section 6.1) with no signature
#[clap(name = "none")]
None,
}

fn is_payload_item(val: &str) -> Result<Option<PayloadItem>, String> {
Expand Down Expand Up @@ -241,20 +245,21 @@ fn time_format(arg: &str) -> Result<TimeFormat, String> {
}
}

pub fn translate_algorithm(alg: &SupportedAlgorithms) -> Algorithm {
pub fn translate_algorithm(alg: &SupportedAlgorithms) -> Option<Algorithm> {
match alg {
SupportedAlgorithms::HS256 => Algorithm::HS256,
SupportedAlgorithms::HS384 => Algorithm::HS384,
SupportedAlgorithms::HS512 => Algorithm::HS512,
SupportedAlgorithms::RS256 => Algorithm::RS256,
SupportedAlgorithms::RS384 => Algorithm::RS384,
SupportedAlgorithms::RS512 => Algorithm::RS512,
SupportedAlgorithms::PS256 => Algorithm::PS256,
SupportedAlgorithms::PS384 => Algorithm::PS384,
SupportedAlgorithms::PS512 => Algorithm::PS512,
SupportedAlgorithms::ES256 => Algorithm::ES256,
SupportedAlgorithms::ES384 => Algorithm::ES384,
SupportedAlgorithms::EdDSA => Algorithm::EdDSA,
SupportedAlgorithms::HS256 => Some(Algorithm::HS256),
SupportedAlgorithms::HS384 => Some(Algorithm::HS384),
SupportedAlgorithms::HS512 => Some(Algorithm::HS512),
SupportedAlgorithms::RS256 => Some(Algorithm::RS256),
SupportedAlgorithms::RS384 => Some(Algorithm::RS384),
SupportedAlgorithms::RS512 => Some(Algorithm::RS512),
SupportedAlgorithms::PS256 => Some(Algorithm::PS256),
SupportedAlgorithms::PS384 => Some(Algorithm::PS384),
SupportedAlgorithms::PS512 => Some(Algorithm::PS512),
SupportedAlgorithms::ES256 => Some(Algorithm::ES256),
SupportedAlgorithms::ES384 => Some(Algorithm::ES384),
SupportedAlgorithms::EdDSA => Some(Algorithm::EdDSA),
SupportedAlgorithms::None => None,
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ fn main() {
});
}
Commands::Decode(arguments) => {
let (validated_token, token_data, format) = decode_token(arguments);
let (validated_token, token_data, format, raw_header) = decode_token(arguments);
let output_path = &arguments.output_path;

exit(
match print_decoded_token(validated_token, token_data, format, output_path) {
match print_decoded_token(validated_token, token_data, format, output_path, raw_header) {
Ok(_) => 0,
_ => 1,
},
Expand Down
128 changes: 118 additions & 10 deletions src/translators/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::utils::{
decoding_key_from_jwks_secret, get_secret_from_file_or_input, write_file, JWTError, JWTResult,
SecretType,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Header, TokenData, Validation};
use serde_derive::{Deserialize, Serialize};
Expand All @@ -21,17 +22,24 @@ pub enum OutputFormat {

#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct TokenOutput {
pub header: Header,
pub header: serde_json::Value,
pub payload: Payload,
}

impl TokenOutput {
fn new(data: TokenData<Payload>) -> Self {
TokenOutput {
header: data.header,
header: serde_json::to_value(&data.header).unwrap(),
payload: data.claims,
}
}

fn new_unsecured(header_json: serde_json::Value, claims: Payload) -> Self {
TokenOutput {
header: header_json,
payload: claims,
}
}
}

pub fn decoding_key_from_secret(
Expand Down Expand Up @@ -88,13 +96,82 @@ pub fn decoding_key_from_secret(
}
}

pub fn decode_token(
arguments: &DecodeArgs,
) -> (
/// Decode an unsecured JWT (alg: "none") by manually parsing the base64url parts.
/// Returns TokenData with the parsed header and claims.
fn decode_unsecured_token(jwt: &str) -> JWTResult<TokenData<Payload>> {
let parts: Vec<&str> = jwt.split('.').collect();
if parts.len() != 3 {
return Err(JWTError::Internal(
"Invalid JWT: expected 3 parts separated by dots".to_string(),
));
}

// The signature part must be empty for unsecured JWTs
if !parts[2].is_empty() {
return Err(JWTError::Internal(
"Unsecured JWT (alg: none) must have an empty signature".to_string(),
));
}

let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|e| JWTError::Internal(format!("Invalid base64 in header: {e}")))?;
// Parse the header as a generic JSON object first, since jsonwebtoken::Header
// does not recognize "none" as a valid algorithm variant.
let header_json: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|e| JWTError::Internal(format!("Invalid JSON in header: {e}")))?;
let typ = header_json
.get("typ")
.and_then(|v| v.as_str())
.map(String::from);
let kid = header_json
.get("kid")
.and_then(|v| v.as_str())
.map(String::from);
let mut header = Header::new(Algorithm::HS256); // placeholder, overridden below
header.typ = typ;
header.kid = kid;

let claims_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| JWTError::Internal(format!("Invalid base64 in payload: {e}")))?;
let claims: Payload = serde_json::from_slice(&claims_bytes)
.map_err(|e| JWTError::Internal(format!("Invalid JSON in payload: {e}")))?;

Ok(TokenData { header, claims })
}

/// Check if a JWT header has alg: "none" by peeking at the base64-decoded header.
fn is_unsecured_jwt(jwt: &str) -> bool {
let header_part = jwt.split('.').next().unwrap_or("");
if let Ok(bytes) = URL_SAFE_NO_PAD.decode(header_part) {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
return value.get("alg").and_then(|a| a.as_str()) == Some("none");
}
}
false
}

/// Extract the raw header JSON from an unsecured JWT for display purposes.
/// The jsonwebtoken::Header struct cannot represent alg: "none", so we
/// preserve the original header as a serde_json::Value.
fn unsecured_header_json(jwt: &str) -> Option<serde_json::Value> {
let header_part = jwt.split('.').next()?;
let bytes = URL_SAFE_NO_PAD.decode(header_part).ok()?;
serde_json::from_slice(&bytes).ok()
}

/// Result of decoding a JWT: (validated, display, format, raw_header_override).
/// The raw_header_override is `Some` for unsecured JWTs (alg: "none") because
/// jsonwebtoken::Header cannot represent that algorithm.
pub type DecodeResult = (
JWTResult<TokenData<Payload>>,
JWTResult<TokenData<Payload>>,
OutputFormat,
) {
Option<serde_json::Value>,
);

pub fn decode_token(arguments: &DecodeArgs) -> DecodeResult {
let jwt = match arguments.jwt.as_str() {
"-" => {
let mut buffer = String::new();
Expand All @@ -110,10 +187,30 @@ pub fn decode_token(
.trim()
.to_owned();

// Handle unsecured JWTs (alg: "none")
if is_unsecured_jwt(&jwt) {
let raw_header = unsecured_header_json(&jwt);
let validated = decode_unsecured_token(&jwt);
let display = decode_unsecured_token(&jwt).map(|mut token| {
if arguments.time_format.is_some() {
token
.claims
.convert_timestamps(arguments.time_format.unwrap_or(super::TimeFormat::UTC));
}
token
});
let format = if arguments.json {
OutputFormat::Json
} else {
OutputFormat::Text
};
return (validated, display, format, raw_header);
}

let header = decode_header(&jwt).ok();

let algorithm = if let Some(alg) = &arguments.algorithm {
translate_algorithm(alg)
translate_algorithm(alg).unwrap_or(Algorithm::HS256)
} else {
header.as_ref().map(|h| h.alg).unwrap_or(Algorithm::HS256)
};
Expand Down Expand Up @@ -172,6 +269,7 @@ pub fn decode_token(
} else {
OutputFormat::Text
},
None,
)
}

Expand All @@ -180,6 +278,7 @@ pub fn print_decoded_token(
token_data: JWTResult<TokenData<Payload>>,
format: OutputFormat,
output_path: &Option<PathBuf>,
raw_header: Option<serde_json::Value>,
) -> JWTResult<()> {
if let Err(err) = &validated_token {
match err {
Expand Down Expand Up @@ -225,16 +324,25 @@ pub fn print_decoded_token(

match (output_path.as_ref(), format, token_data) {
(Some(path), _, Ok(token)) => {
let json = to_string_pretty(&TokenOutput::new(token)).unwrap();
let output = match raw_header {
Some(h) => TokenOutput::new_unsecured(h, token.claims),
None => TokenOutput::new(token),
};
let json = to_string_pretty(&output).unwrap();
write_file(path, json.as_bytes());
println!("Wrote jwt to file {}", path.display());
}
(None, OutputFormat::Json, Ok(token)) => {
println!("{}", to_string_pretty(&TokenOutput::new(token)).unwrap());
let output = match raw_header {
Some(h) => TokenOutput::new_unsecured(h, token.claims),
None => TokenOutput::new(token),
};
println!("{}", to_string_pretty(&output).unwrap());
}
(None, _, Ok(token)) => {
let header_json = raw_header.unwrap_or_else(|| serde_json::to_value(&token.header).unwrap());
bunt::println!("\n{$bold}Token header\n------------{/$}");
println!("{}\n", to_string_pretty(&token.header).unwrap());
println!("{}\n", to_string_pretty(&header_json).unwrap());
bunt::println!("{$bold}Token claims\n------------{/$}");
println!("{}", to_string_pretty(&token.claims).unwrap());
}
Expand Down
62 changes: 54 additions & 8 deletions src/translators/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::cli_config::{translate_algorithm, EncodeArgs};
use crate::translators::{Claims, Payload, PayloadItem};
use crate::utils::{get_secret_from_file_or_input, write_file, JWTError, JWTResult, SecretType};
use atty::Stream;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::Utc;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde_json::{from_str, Value};
Expand Down Expand Up @@ -67,9 +68,33 @@ pub fn encoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResu
}
}

pub fn encode_token(arguments: &EncodeArgs) -> JWTResult<String> {
let algorithm = translate_algorithm(&arguments.algorithm);
let header = create_header(algorithm, arguments.kid.as_ref(), arguments.no_typ);
/// Encode an unsecured JWT (alg: "none") per RFC 7519 Section 6.1.
/// The token has no signature: header.payload. (trailing dot, empty signature).
fn encode_unsecured_token(arguments: &EncodeArgs) -> JWTResult<String> {
let claims = build_claims(arguments);
let claims_json = serde_json::to_string(&claims).map_err(|e| {
JWTError::Internal(format!("Failed to serialize claims: {e}"))
})?;

let mut header_map = serde_json::Map::new();
header_map.insert("alg".into(), Value::String("none".into()));
if !arguments.no_typ {
header_map.insert("typ".into(), Value::String("JWT".into()));
}
if let Some(kid) = &arguments.kid {
header_map.insert("kid".into(), Value::String(kid.clone()));
}
let header_json = serde_json::to_string(&header_map).map_err(|e| {
JWTError::Internal(format!("Failed to serialize header: {e}"))
})?;

let header_b64 = URL_SAFE_NO_PAD.encode(header_json.as_bytes());
let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json.as_bytes());

Ok(format!("{header_b64}.{claims_b64}."))
}

fn build_claims(arguments: &EncodeArgs) -> Claims {
let custom_payloads = arguments.payload.clone();
let custom_payload = arguments
.json
Expand Down Expand Up @@ -114,17 +139,38 @@ pub fn encode_token(arguments: &EncodeArgs) -> JWTResult<String> {
maybe_payloads.append(&mut custom_payload.unwrap_or_default());

let payloads = maybe_payloads.into_iter().flatten().collect();
let claims = match arguments.keep_payload_order {
match arguments.keep_payload_order {
true => Claims::OrderKept(payloads),
false => {
let Payload(_claims) = Payload::from_payloads(payloads);
Claims::Reordered(_claims)
}
};
}
}

pub fn encode_token(arguments: &EncodeArgs) -> JWTResult<String> {
let algorithm = translate_algorithm(&arguments.algorithm);

encoding_key_from_secret(&algorithm, &arguments.secret).and_then(|secret| {
encode(&header, &claims, &secret).map_err(jsonwebtoken::errors::Error::into)
})
match algorithm {
None => {
// Unsecured JWT (alg: "none")
encode_unsecured_token(arguments)
}
Some(alg) => {
let secret_string = arguments.secret.as_deref().ok_or_else(|| {
JWTError::Internal(
"A secret is required when using a signing algorithm. Use -S to provide one."
.to_string(),
)
})?;
let header = create_header(alg, arguments.kid.as_ref(), arguments.no_typ);
let claims = build_claims(arguments);

encoding_key_from_secret(&alg, secret_string).and_then(|secret| {
encode(&header, &claims, &secret).map_err(jsonwebtoken::errors::Error::into)
})
}
}
}

pub fn print_encoded_token(
Expand Down
Loading