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