Skip to content

Commit bd5fbc4

Browse files
committed
support JWKS secret
1 parent ed53a8d commit bd5fbc4

16 files changed

+351
-70
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased - 2023-XX-YY
44

5+
## [1.0.1] - 2024-01-12
6+
7+
- Disable audience validation
8+
- Support JWKS secret from argument and file
9+
- Update libraries
10+
511
## [1.0.0] - 2024-01-11
612

713
- Initial release

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "jwt-ui"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
authors = ["Deepu K Sasidharan <[email protected]>"]
55
description = """
66
A Terminal UI for decoding/encoding JSON Web Tokens

src/app/jwt_decoder.rs

Lines changed: 172 additions & 43 deletions
Large diffs are not rendered by default.

src/app/jwt_encoder.rs

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use jsonwebtoken::{errors::Error, Algorithm, EncodingKey, Header};
22

33
use super::{
44
jwt_decoder::Payload,
5-
jwt_utils::{get_secret, JWTResult},
5+
jwt_utils::{get_secret, JWTError, JWTResult, SecretFileType},
66
models::{ScrollableTxt, TabRoute, TabsState},
77
ActiveBlock, App, Route, RouteId, TextAreaInput, TextInput,
88
};
@@ -108,7 +108,8 @@ pub fn encode_jwt_token(app: &mut App) {
108108
}
109109

110110
pub fn encoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResult<EncodingKey> {
111-
let secret = get_secret(alg, secret_string)?;
111+
let (secret, file_type) = get_secret(alg, secret_string);
112+
let secret = secret?;
112113

113114
match alg {
114115
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => Ok(EncodingKey::from_secret(&secret)),
@@ -117,17 +118,26 @@ pub fn encoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResu
117118
| Algorithm::RS512
118119
| Algorithm::PS256
119120
| Algorithm::PS384
120-
| Algorithm::PS512 => match secret_string.ends_with(".pem") {
121-
true => EncodingKey::from_rsa_pem(&secret).map_err(Error::into),
122-
false => Ok(EncodingKey::from_rsa_der(&secret)),
121+
| Algorithm::PS512 => match file_type {
122+
SecretFileType::Pem => EncodingKey::from_rsa_pem(&secret).map_err(Error::into),
123+
SecretFileType::Der => Ok(EncodingKey::from_rsa_der(&secret)),
124+
_ => Err(JWTError::Internal(format!(
125+
"Invalid secret file type for {alg:?}"
126+
))),
123127
},
124-
Algorithm::ES256 | Algorithm::ES384 => match secret_string.ends_with(".pem") {
125-
true => EncodingKey::from_ec_pem(&secret).map_err(Error::into),
126-
false => Ok(EncodingKey::from_ec_der(&secret)),
128+
Algorithm::ES256 | Algorithm::ES384 => match file_type {
129+
SecretFileType::Pem => EncodingKey::from_ec_pem(&secret).map_err(Error::into),
130+
SecretFileType::Der => Ok(EncodingKey::from_ec_der(&secret)),
131+
_ => Err(JWTError::Internal(format!(
132+
"Invalid secret file type for {alg:?}"
133+
))),
127134
},
128-
Algorithm::EdDSA => match secret_string.ends_with(".pem") {
129-
true => EncodingKey::from_ed_pem(&secret).map_err(Error::into),
130-
false => Ok(EncodingKey::from_ed_der(&secret)),
135+
Algorithm::EdDSA => match file_type {
136+
SecretFileType::Pem => EncodingKey::from_ed_pem(&secret).map_err(Error::into),
137+
SecretFileType::Der => Ok(EncodingKey::from_ed_der(&secret)),
138+
_ => Err(JWTError::Internal(format!(
139+
"Invalid secret file type for {alg:?}"
140+
))),
131141
},
132142
}
133143
}
@@ -179,7 +189,7 @@ mod tests {
179189
];
180190
app.data.encoder.payload.input = claims.clone().into();
181191

182-
app.data.encoder.secret.input = "@./test_data/test_rsa_private.pem".into();
192+
app.data.encoder.secret.input = "@./test_data/test_rsa_private_key.pem".into();
183193

184194
encode_jwt_token(&mut app);
185195
assert_eq!(app.data.error, "");
@@ -198,7 +208,7 @@ mod tests {
198208
.retain(|claim| claim != "exp");
199209
secret_validator.validate_exp = false;
200210

201-
let secret_string = "@./test_data/test_rsa_public.pem";
211+
let secret_string = "@./test_data/test_rsa_public_key.pem";
202212

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

src/app/jwt_utils.rs

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::fmt;
33
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine as _};
44
use jsonwebtoken::{
55
errors::{Error, ErrorKind},
6-
Algorithm,
6+
jwk, Algorithm, DecodingKey, Header,
77
};
88

99
use super::utils::slurp_file;
@@ -56,29 +56,121 @@ impl fmt::Display for JWTError {
5656
}
5757
}
5858

59-
pub fn get_secret(alg: &Algorithm, secret_string: &str) -> JWTResult<Vec<u8>> {
59+
pub enum SecretFileType {
60+
Pem,
61+
Der,
62+
Jwks,
63+
Na,
64+
}
65+
66+
pub fn get_secret(alg: &Algorithm, secret_string: &str) -> (JWTResult<Vec<u8>>, SecretFileType) {
6067
return match alg {
6168
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
6269
if secret_string.starts_with('@') {
63-
slurp_file(&secret_string.chars().skip(1).collect::<String>()).map_err(JWTError::from)
70+
(
71+
slurp_file(&secret_string.chars().skip(1).collect::<String>()).map_err(JWTError::from),
72+
SecretFileType::Na,
73+
)
6474
} else if secret_string.starts_with("b64:") {
65-
base64_engine
66-
.decode(secret_string.chars().skip(4).collect::<String>())
67-
.map_err(JWTError::from)
75+
(
76+
base64_engine
77+
.decode(secret_string.chars().skip(4).collect::<String>())
78+
.map_err(JWTError::from),
79+
SecretFileType::Na,
80+
)
6881
} else {
69-
Ok(secret_string.as_bytes().to_owned())
82+
(Ok(secret_string.as_bytes().to_owned()), SecretFileType::Na)
7083
}
7184
}
72-
_ => {
85+
Algorithm::EdDSA => {
7386
if !&secret_string.starts_with('@') {
74-
return Err(JWTError::Internal(format!(
75-
"Secret for {alg:?} must be a file path starting with @",
76-
)));
87+
return (
88+
Err(JWTError::Internal(format!(
89+
"Secret for {alg:?} must be a file path starting with @",
90+
))),
91+
SecretFileType::Na,
92+
);
93+
}
94+
95+
(
96+
slurp_file(&secret_string.chars().skip(1).collect::<String>()).map_err(JWTError::from),
97+
get_secret_file_type(secret_string),
98+
)
99+
}
100+
_ => {
101+
if secret_string.starts_with('@') {
102+
(
103+
slurp_file(&secret_string.chars().skip(1).collect::<String>()).map_err(JWTError::from),
104+
get_secret_file_type(secret_string),
105+
)
106+
} else {
107+
// allows to read JWKS from argument (e.g. output of 'curl https://auth.domain.com/jwks.json')
108+
(Ok(secret_string.as_bytes().to_vec()), SecretFileType::Jwks)
77109
}
110+
}
111+
};
112+
}
113+
114+
pub fn decoding_key_from_jwks_secret(
115+
secret: &[u8],
116+
header: Option<Header>,
117+
) -> JWTResult<DecodingKey> {
118+
if let Some(h) = header {
119+
return match parse_jwks(secret) {
120+
Some(jwks) => decoding_key_from_jwks(jwks, &h),
121+
None => Err(JWTError::Internal("Invalid jwks secret format".to_string())),
122+
};
123+
}
124+
Err(JWTError::Internal(
125+
"Invalid jwt header for jwks secret".to_string(),
126+
))
127+
}
128+
129+
fn decoding_key_from_jwks(jwks: jwk::JwkSet, header: &Header) -> JWTResult<DecodingKey> {
130+
let kid = match &header.kid {
131+
Some(k) => k.to_owned(),
132+
None => {
133+
return Err(JWTError::Internal(
134+
"Missing 'kid' from jwt header. Required for jwks secret".to_string(),
135+
));
136+
}
137+
};
78138

79-
slurp_file(&secret_string.chars().skip(1).collect::<String>()).map_err(JWTError::from)
139+
let jwk = match jwks.find(&kid) {
140+
Some(j) => j,
141+
None => {
142+
return Err(JWTError::Internal(format!(
143+
"No jwk found for 'kid' {kid:?}",
144+
)));
80145
}
81146
};
147+
148+
match &jwk.algorithm {
149+
jwk::AlgorithmParameters::RSA(rsa) => {
150+
DecodingKey::from_rsa_components(&rsa.n, &rsa.e).map_err(Error::into)
151+
}
152+
jwk::AlgorithmParameters::EllipticCurve(ec) => {
153+
DecodingKey::from_ec_components(&ec.x, &ec.y).map_err(Error::into)
154+
}
155+
_ => Err(JWTError::Internal("Unsupported alg".to_string())),
156+
}
157+
}
158+
159+
fn parse_jwks(secret: &[u8]) -> Option<jwk::JwkSet> {
160+
match serde_json::from_slice(secret) {
161+
Ok(jwks) => Some(jwks),
162+
Err(_) => None,
163+
}
164+
}
165+
166+
fn get_secret_file_type(secret_string: &str) -> SecretFileType {
167+
if secret_string.ends_with(".pem") {
168+
SecretFileType::Pem
169+
} else if secret_string.ends_with(".json") {
170+
SecretFileType::Jwks
171+
} else {
172+
SecretFileType::Der
173+
}
82174
}
83175

84176
fn map_external_error(ext_err: &Error) -> String {
File renamed without changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"keys": [
3+
{
4+
"use": "sig",
5+
"kty": "EC",
6+
"kid": "4h7wt2IHHu_RLR6OtlZjCe_mIt8xAReS0cDEwwWAeKU",
7+
"crv": "P-256",
8+
"x": "w7JAoU_gJbZJvV-zCOvU9yFJq0FNC_edCMRM78P8eQQ",
9+
"y": "wQg1EytcsEmGrM70Gb53oluoDbVhCZ3Uq3hHMslHVb4"
10+
},
11+
{
12+
"use": "enc",
13+
"kty": "EC",
14+
"kid": "4h7wt2IHHu_RLR6OtlZjCe_mIt8xAReS0cDEwwWAeKU",
15+
"crv": "P-256",
16+
"x": "w7JAoU_gJbZJvV-zCOvU9yFJq0FNC_edCMRM78P8eQQ",
17+
"y": "wQg1EytcsEmGrM70Gb53oluoDbVhCZ3Uq3hHMslHVb4"
18+
}
19+
]
20+
}
File renamed without changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MC4CAQAwBQYDK2VwBCIEIOlt2x5aBpWjO8MNAiE7h9nfpZqFDXVBoRAuZu85fWMU
3+
-----END PRIVATE KEY-----

0 commit comments

Comments
 (0)