1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![cfg_attr(docsrs, allow(unused_attributes))]
3#![forbid(unsafe_code)]
4#![deny(missing_docs)]
5#![deny(missing_docs)]
6
7pub mod claims;
45pub mod error;
47pub mod extractors;
49pub mod service;
51
52#[cfg(feature = "jwks")]
54#[cfg_attr(docsrs, doc(cfg(feature = "jwks")))]
55pub mod jwks;
56
57#[cfg(feature = "revocation")]
60#[cfg_attr(docsrs, doc(cfg(feature = "revocation")))]
61pub mod revocation;
62
63#[allow(
67 clippy::unwrap_used,
68 clippy::expect_used,
69 clippy::indexing_slicing,
70 clippy::panic
71)]
72#[cfg(test)]
73mod tests {
74 use super::claims::StandardClaims;
75 use super::error::JwtError;
76 use super::extractors::{build_auth_cookie, extract_bearer_token};
77 use super::service::{JwtConfig, JwtService};
78 use serde::{Deserialize, Serialize};
79
80 #[derive(Debug, Serialize, Deserialize, PartialEq)]
81 struct NumericClaims {
82 sub: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 exp: Option<u64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 iss: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 aud: Option<String>,
89 }
90
91 fn now_plus_secs(secs: u64) -> u64 {
92 chrono::Utc::now().timestamp() as u64 + secs
93 }
94
95 #[test]
96 fn jwt_config_default_is_hs256() {
97 let config = JwtConfig::default();
98 assert_eq!(config.algorithm, super::service::JwtAlgorithm::HS256);
99 assert_eq!(config.access_token_ttl, 3600);
100 assert_eq!(config.refresh_token_ttl, 604800);
101 }
102
103 #[test]
104 fn jwt_config_short_secret_works_for_hmac() {
105 let config = JwtConfig {
106 secret: "ab".to_string(),
107 issuer: Some("test-issuer".to_string()),
108 ..Default::default()
109 };
110 let service = JwtService::new(config);
111 let claims = NumericClaims {
112 sub: Some("user-1".to_string()),
113 exp: Some(now_plus_secs(3600)),
114 iss: Some("test-issuer".to_string()),
115 aud: None,
116 };
117 let token = service.encode(&claims).unwrap();
118 let decoded: NumericClaims = service.decode(&token).unwrap();
119 assert_eq!(decoded.sub.as_deref(), Some("user-1"));
120 }
121
122 #[test]
123 fn jwt_service_encode_decode_roundtrip() {
124 let config = JwtConfig {
125 secret: "a-valid-secret-key-for-testing".to_string(),
126 issuer: Some("test-issuer".to_string()),
127 ..Default::default()
128 };
129 let service = JwtService::new(config);
130
131 let claims = NumericClaims {
132 sub: Some("user-42".to_string()),
133 exp: Some(now_plus_secs(3600)),
134 iss: Some("test-issuer".to_string()),
135 aud: None,
136 };
137
138 let token = service.encode(&claims).unwrap();
139 let decoded: NumericClaims = service.decode(&token).unwrap();
140
141 assert_eq!(decoded.sub.as_deref(), Some("user-42"));
142 assert_eq!(decoded.iss.as_deref(), Some("test-issuer"));
143 }
144
145 #[test]
146 fn jwt_service_encode_decode_with_issuer_audience() {
147 let config = JwtConfig {
148 secret: "test-secret-key-123".to_string(),
149 issuer: Some("test-issuer".to_string()),
150 audience: Some("test-audience".to_string()),
151 ..Default::default()
152 };
153 let service = JwtService::new(config);
154
155 let claims = NumericClaims {
156 sub: Some("user-1".to_string()),
157 exp: Some(now_plus_secs(3600)),
158 iss: Some("test-issuer".to_string()),
159 aud: Some("test-audience".to_string()),
160 };
161
162 let token = service.encode(&claims).unwrap();
163 let decoded: NumericClaims = service.decode(&token).unwrap();
164 assert_eq!(decoded.iss.as_deref(), Some("test-issuer"));
165 assert_eq!(decoded.aud.as_deref(), Some("test-audience"));
166 }
167
168 #[test]
169 fn jwt_service_wrong_secret_fails() {
170 let config1 = JwtConfig {
171 secret: "secret-one".to_string(),
172 ..Default::default()
173 };
174 let config2 = JwtConfig {
175 secret: "secret-two".to_string(),
176 ..Default::default()
177 };
178 let service1 = JwtService::new(config1);
179 let service2 = JwtService::new(config2);
180
181 let claims = NumericClaims {
182 sub: Some("user-1".to_string()),
183 exp: Some(now_plus_secs(3600)),
184 iss: None,
185 aud: None,
186 };
187 let token = service1.encode(&claims).unwrap();
188 let result = service2.decode::<NumericClaims>(&token);
189 assert!(result.is_err());
190 }
191
192 #[test]
193 fn standard_claims_serialization_roundtrip() {
194 let claims = StandardClaims {
195 sub: Some("user-100".to_string()),
196 iss: Some("issuer".to_string()),
197 aud: Some("audience".to_string()),
198 role: Some("editor".to_string()),
199 permissions: vec!["edit".to_string()],
200 ..Default::default()
201 };
202
203 let json = serde_json::to_string(&claims).unwrap();
204 let deserialized: StandardClaims = serde_json::from_str(&json).unwrap();
205
206 assert_eq!(deserialized.sub.as_deref(), Some("user-100"));
207 assert_eq!(deserialized.iss.as_deref(), Some("issuer"));
208 assert_eq!(deserialized.aud.as_deref(), Some("audience"));
209 assert_eq!(deserialized.role.as_deref(), Some("editor"));
210 assert_eq!(deserialized.permissions, vec!["edit"]);
211 }
212
213 #[test]
214 fn standard_claims_skips_none_fields() {
215 let claims = StandardClaims::default();
216 let json = serde_json::to_string(&claims).unwrap();
217 assert!(!json.contains("sub"));
218 assert!(!json.contains("iss"));
219 assert!(!json.contains("role"));
220 }
221
222 #[test]
223 fn standard_claims_with_extra_fields() {
224 let mut extra = std::collections::HashMap::new();
225 extra.insert("custom_key".to_string(), serde_json::json!("custom_value"));
226 let claims = StandardClaims {
227 sub: Some("user-1".to_string()),
228 extra,
229 ..Default::default()
230 };
231 let json = serde_json::to_string(&claims).unwrap();
232 let deserialized: StandardClaims = serde_json::from_str(&json).unwrap();
233 assert_eq!(
234 deserialized.extra.get("custom_key").unwrap(),
235 &serde_json::json!("custom_value")
236 );
237 }
238
239 #[test]
240 fn extract_bearer_token_valid() {
241 assert_eq!(
242 extract_bearer_token("Bearer abc123"),
243 Some("abc123".to_string())
244 );
245 }
246
247 #[test]
248 fn extract_bearer_token_with_spaces() {
249 assert_eq!(
250 extract_bearer_token("Bearer token-with-spaces "),
251 Some("token-with-spaces".to_string())
252 );
253 }
254
255 #[test]
256 fn extract_bearer_token_wrong_scheme() {
257 assert_eq!(extract_bearer_token("Basic abc123"), None);
258 }
259
260 #[test]
261 fn extract_bearer_token_empty() {
262 assert_eq!(extract_bearer_token("Bearer "), None);
263 assert_eq!(extract_bearer_token(""), None);
264 }
265
266 #[test]
267 fn build_auth_cookie_format() {
268 let cookie = build_auth_cookie("session", "jwt_token_here", 3600, true);
269 assert!(cookie.contains("session=jwt_token_here"));
270 assert!(cookie.contains("Max-Age=3600"));
271 assert!(cookie.contains("Path=/"));
272 assert!(cookie.contains("HttpOnly"));
273 assert!(cookie.contains("SameSite=Strict"));
274 assert!(cookie.contains("Secure"));
275 }
276
277 #[test]
278 fn build_auth_cookie_no_secure() {
279 let cookie = build_auth_cookie("sid", "tok", 600, false);
280 assert!(!cookie.contains("Secure"));
281 assert!(cookie.contains("sid=tok"));
282 }
283
284 #[test]
285 fn jwt_error_display_messages() {
286 assert_eq!(JwtError::EncodingFailed.to_string(), "failed to encode JWT");
287 assert_eq!(
288 JwtError::DecodingFailed("bad".to_string()).to_string(),
289 "failed to decode JWT: bad"
290 );
291 assert_eq!(JwtError::Expired.to_string(), "token has expired");
292 assert_eq!(JwtError::InvalidSignature.to_string(), "invalid signature");
293 assert_eq!(JwtError::Revoked.to_string(), "token has been revoked");
294 assert_eq!(
295 JwtError::InvalidSecret("weak".to_string()).to_string(),
296 "invalid secret or key: weak"
297 );
298 assert_eq!(
299 JwtError::KeyLoading("pem err".to_string()).to_string(),
300 "failed to load signing key: pem err"
301 );
302 }
303
304 #[test]
307 fn jwt_config_debug_redacts_secret() {
308 let config = JwtConfig {
309 secret: "super-secret-value".to_string(),
310 issuer: Some("iss".to_string()),
311 ..Default::default()
312 };
313 let dbg = format!("{config:?}");
314 assert!(!dbg.contains("super-secret-value"));
315 assert!(dbg.contains("<redacted>"));
316 }
317
318 #[test]
320 fn expired_token_rejected() {
321 let config = JwtConfig {
322 secret: "expired-test-secret".to_string(),
323 ..Default::default()
324 };
325 let service = JwtService::new(config);
326 let claims = NumericClaims {
327 sub: Some("user-1".to_string()),
328 exp: Some(now_plus_secs(0).saturating_sub(3600)), iss: None,
330 aud: None,
331 };
332 let token = service.encode(&claims).unwrap();
333 let result = service.decode::<NumericClaims>(&token);
334 assert!(result.is_err());
335 }
336
337 #[test]
340 fn missing_required_claims_rejected() {
341 #[derive(serde::Serialize, serde::Deserialize)]
342 struct BareClaims {
343 sub: Option<String>,
344 }
345 let config = JwtConfig {
346 secret: "required-claims-secret".to_string(),
347 ..Default::default()
348 };
349 let service = JwtService::new(config);
350 let token = service
351 .encode(&BareClaims {
352 sub: Some("user-1".to_string()),
353 })
354 .unwrap();
355 let result = service.decode::<NumericClaims>(&token);
356 assert!(result.is_err());
357 }
358
359 #[test]
362 fn issuer_mismatch_rejected() {
363 let config = JwtConfig {
364 secret: "issuer-test-secret".to_string(),
365 issuer: Some("expected-issuer".to_string()),
366 ..Default::default()
367 };
368 let service = JwtService::new(config);
369 let claims = NumericClaims {
370 sub: Some("user-1".to_string()),
371 exp: Some(now_plus_secs(3600)),
372 iss: Some("evil-issuer".to_string()),
373 aud: None,
374 };
375 let token = service.encode(&claims).unwrap();
376 let result = service.decode::<NumericClaims>(&token);
377 assert!(result.is_err());
378 }
379
380 #[test]
383 fn audience_mismatch_rejected() {
384 let config = JwtConfig {
385 secret: "aud-test-secret".to_string(),
386 audience: Some("expected-aud".to_string()),
387 ..Default::default()
388 };
389 let service = JwtService::new(config);
390 let claims = NumericClaims {
391 sub: Some("user-1".to_string()),
392 exp: Some(now_plus_secs(3600)),
393 iss: None,
394 aud: Some("other-aud".to_string()),
395 };
396 let token = service.encode(&claims).unwrap();
397 let result = service.decode::<NumericClaims>(&token);
398 assert!(result.is_err());
399 }
400
401 #[test]
405 fn cross_algorithm_token_rejected() {
406 let hs384_service = JwtService::new(JwtConfig {
407 secret: "shared-secret-value".to_string(),
408 algorithm: super::service::JwtAlgorithm::HS384,
409 ..Default::default()
410 });
411 let hs256_service = JwtService::new(JwtConfig {
412 secret: "shared-secret-value".to_string(),
413 algorithm: super::service::JwtAlgorithm::HS256,
414 ..Default::default()
415 });
416 let claims = NumericClaims {
421 sub: Some("user-1".to_string()),
422 exp: Some(now_plus_secs(3600)),
423 iss: Some("issuer".to_string()),
424 aud: None,
425 };
426 let token = hs384_service.encode(&claims).unwrap();
427 let result = hs256_service.decode::<NumericClaims>(&token);
428 assert!(result.is_err());
429 }
430
431 #[test]
435 fn jwt_config_debug_shows_algorithm() {
436 let hs384 = JwtConfig {
437 algorithm: super::service::JwtAlgorithm::HS384,
438 ..Default::default()
439 };
440 assert!(format!("{hs384:?}").contains("HS384"), "{hs384:?}");
441
442 let rs256 = JwtConfig {
443 algorithm: super::service::JwtAlgorithm::RS256,
444 ..Default::default()
445 };
446 assert!(format!("{rs256:?}").contains("RS256"), "{rs256:?}");
447 }
448
449 #[test]
452 fn encode_standard_mints_a_valid_token() {
453 let config = JwtConfig {
454 secret: "standard-mint-secret".to_string(),
455 issuer: Some("issuer".to_string()),
456 ..Default::default()
457 };
458 let service = JwtService::new(config);
459 let claims = StandardClaims {
460 sub: Some("user-1".to_string()),
461 ..Default::default()
462 };
463 let token = service.encode_standard(claims).unwrap();
464 let decoded = service.validate(&token).unwrap();
465 assert_eq!(decoded.sub.as_deref(), Some("user-1"));
466 }
467
468 #[test]
471 fn validate_rejects_expired_and_returns_real_claims() {
472 let config = JwtConfig {
473 secret: "validate-test-secret".to_string(),
474 issuer: Some("issuer".to_string()),
475 ..Default::default()
476 };
477 let service = JwtService::new(config);
478
479 let expired = NumericClaims {
480 sub: Some("user-1".to_string()),
481 exp: Some(now_plus_secs(0).saturating_sub(3600)),
482 iss: Some("issuer".to_string()),
483 aud: None,
484 };
485 let token = service.encode(&expired).unwrap();
486 assert!(service.validate(&token).is_err());
487
488 let fresh = NumericClaims {
489 sub: Some("user-2".to_string()),
490 exp: Some(now_plus_secs(3600)),
491 iss: Some("issuer".to_string()),
492 aud: None,
493 };
494 let token = service.encode(&fresh).unwrap();
495 let claims = service.validate(&token).unwrap();
496 assert_eq!(claims.sub.as_deref(), Some("user-2"));
497 }
498
499 #[cfg(feature = "rotation")]
502 #[test]
503 fn rotation_accepts_old_secret_tokens() {
504 let old_service = JwtService::new(JwtConfig {
505 secret: "old-secret".to_string(),
506 ..Default::default()
507 });
508 let claims = NumericClaims {
509 sub: Some("user-1".to_string()),
510 exp: Some(now_plus_secs(3600)),
511 iss: Some("issuer".to_string()),
512 aud: None,
513 };
514 let token = old_service.encode(&claims).unwrap();
515
516 let rotated = JwtService::new(JwtConfig::with_rotation_secrets(
518 super::service::JwtAlgorithm::HS256,
519 vec!["new-secret".to_string(), "old-secret".to_string()],
520 ));
521 let decoded: NumericClaims = rotated.decode(&token).unwrap();
522 assert_eq!(decoded.sub.as_deref(), Some("user-1"));
523
524 let new_token = rotated.encode(&claims).unwrap();
526 let primary_only = JwtService::new(JwtConfig {
527 secret: "new-secret".to_string(),
528 ..Default::default()
529 });
530 assert!(primary_only.decode::<NumericClaims>(&new_token).is_ok());
531 assert!(primary_only.decode::<NumericClaims>(&token).is_err());
532 }
533
534 #[cfg(feature = "revocation")]
538 #[test]
539 fn revoked_token_rejected_by_decode_standard() {
540 use super::revocation::InMemoryRevocationStore;
541 use std::sync::Arc;
542
543 let rt = tokio::runtime::Runtime::new().unwrap();
546
547 let secret = "revocation-test-secret".to_string();
548 let store = Arc::new(InMemoryRevocationStore::new());
549
550 let service = JwtService::new(JwtConfig {
551 secret,
552 ..Default::default()
553 })
554 .with_revocation(Box::new(InMemoryRevocationStore::new()));
555
556 let claims = StandardClaims {
557 sub: Some("user-1".to_string()),
558 iss: Some("issuer".to_string()),
559 jti: Some("jti-to-revoke".to_string()),
560 exp: Some(chrono::Utc::now() + chrono::Duration::seconds(3600)),
561 ..Default::default()
562 };
563 let token = service.encode_standard(claims).unwrap();
564
565 rt.block_on(async {
568 use super::revocation::TokenRevocationStore as _;
569
570 assert!(service.decode_standard(&token).await.is_ok());
572
573 store.revoke("jti-to-revoke").await.unwrap();
576 assert!(store.is_revoked("jti-to-revoke").await.unwrap());
577 store.revoke("jti-to-revoke").await.unwrap();
579
580 let service_with_state = JwtService::new(JwtConfig {
581 secret: "revocation-test-secret".to_string(),
582 ..Default::default()
583 })
584 .with_revocation(Box::new(SharedStore(Arc::clone(&store))));
585 let err = service_with_state
586 .decode_standard(&token)
587 .await
588 .expect_err("revoked token must be rejected");
589 assert!(matches!(err, JwtError::Revoked));
590 });
591 }
592
593 #[cfg(feature = "revocation")]
595 struct SharedStore(std::sync::Arc<super::revocation::InMemoryRevocationStore>);
596
597 #[cfg(feature = "revocation")]
598 #[async_trait::async_trait]
599 impl super::revocation::TokenRevocationStore for SharedStore {
600 async fn revoke(&self, jti: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
601 self.0.revoke(jti).await
602 }
603
604 async fn is_revoked(
605 &self,
606 jti: &str,
607 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
608 self.0.is_revoked(jti).await
609 }
610 }
611}
612
613#[allow(
617 clippy::unwrap_used,
618 clippy::expect_used,
619 clippy::indexing_slicing,
620 clippy::panic
621)]
622#[cfg(test)]
623mod proptest_tests {
624 use super::service::{JwtConfig, JwtService};
625 use proptest::prelude::*;
626 use serde::{Deserialize, Serialize};
627
628 #[derive(Debug, Serialize, Deserialize, PartialEq)]
629 struct NumericClaims {
630 sub: Option<String>,
631 #[serde(skip_serializing_if = "Option::is_none")]
632 exp: Option<u64>,
633 #[serde(skip_serializing_if = "Option::is_none")]
634 iss: Option<String>,
635 #[serde(skip_serializing_if = "Option::is_none")]
636 aud: Option<String>,
637 }
638
639 fn arb_standard_claims() -> impl Strategy<Value = super::claims::StandardClaims> {
640 (
641 prop::option::of("[a-z0-9_-]{1,20}"),
642 prop::option::of("[a-z0-9_-]{1,20}"),
643 prop::option::of("[a-z]{1,10}"),
644 prop::collection::vec("[a-z]{1,10}", 0..5),
645 )
646 .prop_map(
647 |(sub, aud, role, permissions)| super::claims::StandardClaims {
648 sub,
649 iss: None,
650 aud,
651 exp: None,
652 iat: None,
653 jti: None,
654 role,
655 permissions,
656 extra: std::collections::HashMap::new(),
657 },
658 )
659 }
660
661 proptest! {
662 #[test]
663 fn jwt_roundtrip(sub in "[a-z0-9_-]{1,20}") {
664 let config = JwtConfig {
665 secret: "test-secret-key-12345".to_string(),
666 issuer: Some("test-issuer".to_string()),
667 ..Default::default()
668 };
669 let service = JwtService::new(config);
670 let claims = NumericClaims {
671 sub: Some(sub.clone()),
672 exp: Some(chrono::Utc::now().timestamp() as u64 + 3600),
673 iss: Some("test-issuer".to_string()),
674 aud: None,
675 };
676 let token = service.encode(&claims).unwrap();
677 let decoded: NumericClaims = service.decode(&token).unwrap();
678 prop_assert_eq!(decoded.sub.as_deref(), Some(sub.as_str()));
679 prop_assert_eq!(decoded.iss.as_deref(), Some("test-issuer"));
680 }
681
682 #[test]
683 fn jwt_wrong_secret_fails(iss in "[a-z0-9_-]{1,20}") {
684 let config1 = JwtConfig {
685 secret: "secret-one-for-testing".to_string(),
686 issuer: Some(iss.clone()),
687 ..Default::default()
688 };
689 let config2 = JwtConfig {
690 secret: "secret-two-for-testing".to_string(),
691 issuer: Some(iss),
692 ..Default::default()
693 };
694 let service1 = JwtService::new(config1);
695 let service2 = JwtService::new(config2);
696 let claims = NumericClaims {
697 sub: Some("user-1".to_string()),
698 exp: Some(chrono::Utc::now().timestamp() as u64 + 3600),
699 iss: None,
700 aud: None,
701 };
702 let token = service1.encode(&claims).unwrap();
703 let result = service2.decode::<NumericClaims>(&token);
704 prop_assert!(result.is_err());
705 }
706
707 #[test]
708 fn jwt_malformed_token_fails(data in "\\PC{1,500}") {
709 let config = JwtConfig {
710 secret: "a-valid-secret".to_string(),
711 ..Default::default()
712 };
713 let service = JwtService::new(config);
714 let result = service.decode::<NumericClaims>(&data);
715 prop_assert!(result.is_err());
716 }
717
718 #[test]
719 fn claims_serialization_roundtrip(claims in arb_standard_claims()) {
720 let json = serde_json::to_string(&claims).unwrap();
721 let deserialized: super::claims::StandardClaims = serde_json::from_str(&json).unwrap();
722 prop_assert_eq!(deserialized.sub, claims.sub);
723 prop_assert_eq!(deserialized.iss, claims.iss);
724 prop_assert_eq!(deserialized.aud, claims.aud);
725 prop_assert_eq!(deserialized.role, claims.role);
726 prop_assert_eq!(deserialized.permissions, claims.permissions);
727 }
728 }
729}