1use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
2use rskit_errors::{AppError, AppResult, ErrorCode};
3use serde::{Serialize, de::DeserializeOwned};
4
5use super::config::{AsymmetricAlgorithm, JwtAlgorithm, JwtConfig, JwtKeyMaterial};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub struct JwtHeader {
11 pub algorithm: JwtAlgorithm,
13 pub token_type: Option<String>,
15 pub key_id: Option<String>,
17 pub content_type: Option<String>,
19}
20
21impl TryFrom<Header> for JwtHeader {
22 type Error = AppError;
23
24 fn try_from(header: Header) -> AppResult<Self> {
25 Ok(Self {
26 algorithm: algorithm_from_jsonwebtoken(header.alg)?,
27 token_type: header.typ,
28 key_id: header.kid,
29 content_type: header.cty,
30 })
31 }
32}
33
34pub struct JwtCodec {
40 config: JwtConfig,
41 encoding_key: EncodingKey,
42 decoding_key: DecodingKey,
43}
44
45impl JwtCodec {
46 pub fn new(config: JwtConfig) -> AppResult<Self> {
51 validate_config(&config)?;
52 let (encoding_key, decoding_key) = build_keys(&config.key_material)?;
53 Ok(Self {
54 config,
55 encoding_key,
56 decoding_key,
57 })
58 }
59
60 #[must_use]
62 pub const fn config(&self) -> &JwtConfig {
63 &self.config
64 }
65
66 pub fn encode<C: Serialize>(&self, claims: &C) -> AppResult<String> {
75 let mut header = Header::new(self.config.algorithm().as_jsonwebtoken());
76 header.typ = Some("JWT".to_string());
77 jsonwebtoken::encode(&header, claims, &self.encoding_key).map_err(|error| {
78 AppError::new(
79 ErrorCode::Internal,
80 format!(
81 "JWT encode error for {:?}: {error}",
82 self.config.algorithm()
83 ),
84 )
85 })
86 }
87
88 pub fn decode<C: DeserializeOwned>(&self, token: &str) -> AppResult<C> {
94 let header =
95 jsonwebtoken::decode_header(token).map_err(|error| map_validation_error(&error))?;
96 let configured_algorithm = self.config.algorithm().as_jsonwebtoken();
97 if header.alg != configured_algorithm {
98 return Err(AppError::invalid_token().context("JWT algorithm mismatch"));
99 }
100
101 let validation = validation_for(&self.config);
102 let data = jsonwebtoken::decode::<C>(token, &self.decoding_key, &validation)
103 .map_err(|error| map_validation_error(&error))?;
104 Ok(data.claims)
105 }
106
107 pub fn decode_header(token: &str) -> AppResult<JwtHeader> {
113 jsonwebtoken::decode_header(token)
114 .map_err(|error| map_validation_error(&error))
115 .and_then(JwtHeader::try_from)
116 }
117}
118
119fn validate_config(config: &JwtConfig) -> AppResult<()> {
120 if config.issuer.trim().is_empty() {
121 return Err(AppError::invalid_input(
122 "issuer",
123 "issuer must not be empty",
124 ));
125 }
126 if config.audience.is_empty() {
127 return Err(AppError::invalid_input(
128 "audience",
129 "at least one audience value is required",
130 ));
131 }
132 if config
133 .audience
134 .iter()
135 .any(|audience| audience.trim().is_empty())
136 {
137 return Err(AppError::invalid_input(
138 "audience",
139 "audience values must not be empty",
140 ));
141 }
142 if config.leeway.as_secs() > 60 {
143 return Err(AppError::invalid_input(
144 "leeway",
145 "clock skew tolerance must be 60 seconds or less",
146 ));
147 }
148 Ok(())
149}
150
151fn build_keys(key_material: &JwtKeyMaterial) -> AppResult<(EncodingKey, DecodingKey)> {
152 match key_material {
153 JwtKeyMaterial::Hs256Internal { secret } => {
154 if secret.is_empty() {
155 return Err(AppError::invalid_input(
156 "secret",
157 "HMAC secret must not be empty",
158 ));
159 }
160 if secret.len() < 32 {
161 return Err(AppError::invalid_input(
162 "secret",
163 "HMAC secret must be at least 32 bytes",
164 ));
165 }
166 Ok((
167 EncodingKey::from_secret(secret.expose().as_bytes()),
168 DecodingKey::from_secret(secret.expose().as_bytes()),
169 ))
170 }
171 JwtKeyMaterial::Asymmetric { algorithm, keys } => {
172 let priv_pem = keys.private_key_pem.expose().as_bytes();
173 let pub_pem = keys.public_key_pem.expose().as_bytes();
174 let (enc, dec) = match algorithm {
175 AsymmetricAlgorithm::Rs256 => (
176 EncodingKey::from_rsa_pem(priv_pem),
177 DecodingKey::from_rsa_pem(pub_pem),
178 ),
179 AsymmetricAlgorithm::Es256 => (
180 EncodingKey::from_ec_pem(priv_pem),
181 DecodingKey::from_ec_pem(pub_pem),
182 ),
183 AsymmetricAlgorithm::EdDsa => (
184 EncodingKey::from_ed_pem(priv_pem),
185 DecodingKey::from_ed_pem(pub_pem),
186 ),
187 };
188 Ok((
189 enc.map_err(|e| jwt_key_error(&e))?,
190 dec.map_err(|e| jwt_key_error(&e))?,
191 ))
192 }
193 }
194}
195
196fn jwt_key_error(error: &jsonwebtoken::errors::Error) -> AppError {
197 AppError::new(
198 ErrorCode::InvalidInput,
199 format!("invalid JWT key material: {error}"),
200 )
201}
202
203fn validation_for(config: &JwtConfig) -> Validation {
204 let mut validation = Validation::new(config.algorithm().as_jsonwebtoken());
205 validation.leeway = config.leeway.as_secs();
206 validation.validate_nbf = true;
207 validation.set_issuer(&[config.issuer.as_str()]);
208 validation.set_audience(&config.audience);
209 validation.set_required_spec_claims(&["exp", "nbf", "iss", "aud", "sub", "iat"]);
210 validation.algorithms = vec![config.algorithm().as_jsonwebtoken()];
211 validation
212}
213
214fn map_validation_error(error: &jsonwebtoken::errors::Error) -> AppError {
215 match error.kind() {
216 jsonwebtoken::errors::ErrorKind::ExpiredSignature => AppError::token_expired(),
217 jsonwebtoken::errors::ErrorKind::InvalidAlgorithm
218 | jsonwebtoken::errors::ErrorKind::InvalidSignature
219 | jsonwebtoken::errors::ErrorKind::InvalidToken
220 | jsonwebtoken::errors::ErrorKind::InvalidIssuer
221 | jsonwebtoken::errors::ErrorKind::InvalidAudience
222 | jsonwebtoken::errors::ErrorKind::ImmatureSignature
223 | jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(_) => AppError::invalid_token(),
224 _ => AppError::new(
225 ErrorCode::Unauthorized,
226 format!("JWT validation failed: {error}"),
227 ),
228 }
229}
230
231fn algorithm_from_jsonwebtoken(algorithm: jsonwebtoken::Algorithm) -> AppResult<JwtAlgorithm> {
232 match algorithm {
233 jsonwebtoken::Algorithm::HS256 => Ok(JwtAlgorithm::Hs256Internal),
234 jsonwebtoken::Algorithm::RS256 => Ok(JwtAlgorithm::Rs256),
235 jsonwebtoken::Algorithm::ES256 => Ok(JwtAlgorithm::Es256),
236 jsonwebtoken::Algorithm::EdDSA => Ok(JwtAlgorithm::EdDsa),
237 _ => Err(AppError::invalid_token().context("JWT algorithm is not allowed by rskit")),
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use serde::{Deserialize, Serialize};
244
245 use super::*;
246
247 const ISSUER: &str = "https://issuer.example";
248 const AUDIENCE: &str = "rskit-tests";
249
250 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251 struct Claims {
252 sub: String,
253 iss: String,
254 aud: Vec<String>,
255 exp: u64,
256 nbf: u64,
257 iat: u64,
258 }
259
260 fn now() -> u64 {
261 std::time::SystemTime::now()
262 .duration_since(std::time::UNIX_EPOCH)
263 .unwrap()
264 .as_secs()
265 }
266
267 fn claims() -> Claims {
268 let now = now();
269 Claims {
270 sub: "user-1".to_owned(),
271 iss: ISSUER.to_owned(),
272 aud: vec![AUDIENCE.to_owned()],
273 exp: now + 3600,
274 nbf: now.saturating_sub(1),
275 iat: now,
276 }
277 }
278
279 fn codec() -> JwtCodec {
280 JwtCodec::new(JwtConfig::hs256_internal(
281 "codec-test-secret-32-bytes-long!",
282 ISSUER,
283 vec![AUDIENCE.to_owned()],
284 ))
285 .unwrap()
286 }
287
288 #[test]
289 fn codec_roundtrip_and_header_decode_use_rskit_types() {
290 let codec = codec();
291 let token = codec.encode(&claims()).unwrap();
292
293 let header = JwtCodec::decode_header(&token).unwrap();
294 assert_eq!(header.algorithm, JwtAlgorithm::Hs256Internal);
295 assert_eq!(header.token_type.as_deref(), Some("JWT"));
296
297 let decoded: Claims = codec.decode(&token).unwrap();
298 assert_eq!(decoded.sub, "user-1");
299 }
300
301 #[test]
302 fn codec_decode_rejects_missing_required_claims() {
303 let codec = codec();
304 let mut claims = serde_json::json!({
305 "sub": "user-1",
306 "iss": ISSUER,
307 "aud": [AUDIENCE],
308 "exp": now() + 3600,
309 "nbf": now().saturating_sub(1),
310 "iat": now(),
311 });
312 claims.as_object_mut().unwrap().remove("aud");
313 let token = codec.encode(&claims).unwrap();
314
315 let result = codec.decode::<serde_json::Value>(&token);
316
317 assert!(result.is_err());
318 }
319
320 #[test]
321 fn codec_rejects_blank_audience_values() {
322 let err = JwtCodec::new(JwtConfig::hs256_internal(
323 "codec-test-secret-32-bytes-long!",
324 ISSUER,
325 vec![AUDIENCE.to_owned(), " \t ".to_owned()],
326 ))
327 .err()
328 .unwrap();
329
330 assert_eq!(err.code(), ErrorCode::InvalidInput);
331 }
332
333 #[test]
334 fn codec_rejects_invalid_policy_and_key_material() {
335 for config in [
336 JwtConfig::hs256_internal(
337 "codec-test-secret-32-bytes-long!",
338 " \t ",
339 vec![AUDIENCE.to_owned()],
340 ),
341 JwtConfig::hs256_internal("codec-test-secret-32-bytes-long!", ISSUER, Vec::new()),
342 JwtConfig {
343 leeway: std::time::Duration::from_secs(61),
344 ..JwtConfig::hs256_internal(
345 "codec-test-secret-32-bytes-long!",
346 ISSUER,
347 vec![AUDIENCE.to_owned()],
348 )
349 },
350 JwtConfig::hs256_internal("", ISSUER, vec![AUDIENCE.to_owned()]),
351 JwtConfig::hs256_internal("too-short", ISSUER, vec![AUDIENCE.to_owned()]),
352 ] {
353 assert_eq!(
354 JwtCodec::new(config).err().unwrap().code(),
355 ErrorCode::InvalidInput
356 );
357 }
358
359 let asymmetric = JwtConfig::rs256(
360 "not a private key",
361 "not a public key",
362 ISSUER,
363 vec![AUDIENCE.to_owned()],
364 );
365 assert_eq!(
366 JwtCodec::new(asymmetric).err().unwrap().code(),
367 ErrorCode::InvalidInput
368 );
369 }
370
371 #[test]
372 fn codec_rejects_algorithm_mismatch_and_malformed_header() {
373 let codec = codec();
374 let mut header = Header::new(jsonwebtoken::Algorithm::HS384);
375 header.typ = Some("JWT".to_string());
376 let token = jsonwebtoken::encode(
377 &header,
378 &claims(),
379 &EncodingKey::from_secret(b"codec-test-secret-32-bytes-long!"),
380 )
381 .unwrap();
382
383 assert_eq!(
384 codec.decode::<Claims>(&token).unwrap_err().code(),
385 ErrorCode::InvalidToken
386 );
387 assert_eq!(
388 JwtCodec::decode_header("not-a-jwt").unwrap_err().code(),
389 ErrorCode::InvalidToken
390 );
391 }
392}