Skip to main content

tokenkit/
lib.rs

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
7//! Type-safe JWT encode/decode for Rust with configurable validation,
8//! secret rotation, key rotation, and revocation support.
9//!
10//! # Quick Start
11//!
12//! ```
13//! use tokenkit::service::{JwtConfig, JwtService, JwtAlgorithm};
14//! use tokenkit::claims::StandardClaims;
15//! use serde::{Serialize, Deserialize};
16//!
17//! #[derive(Serialize, Deserialize)]
18//! struct Claims {
19//!     sub: Option<String>,
20//!     exp: Option<u64>,
21//!     iss: Option<String>,
22//! }
23//!
24//! let config = JwtConfig {
25//!     algorithm: JwtAlgorithm::HS256,
26//!     secret: "my-secret-key".to_string(),
27//!     issuer: Some("my-app".to_string()),
28//!     ..Default::default()
29//! };
30//!
31//! let service = JwtService::new(config);
32//! let claims = Claims {
33//!     sub: Some("user-123".to_string()),
34//!     exp: Some(chrono::Utc::now().timestamp() as u64 + 3600),
35//!     iss: Some("my-app".to_string()),
36//! };
37//!
38//! let token = service.encode(&claims).unwrap();
39//! let decoded: Claims = service.decode(&token).unwrap();
40//! assert_eq!(decoded.sub.as_deref(), Some("user-123"));
41//! ```
42
43/// JWT claims types.
44pub mod claims;
45/// Error types.
46pub mod error;
47/// Extractors for HTTP authorization headers and cookies.
48pub mod extractors;
49/// JWT service for encoding, decoding, and validation.
50pub mod service;
51
52/// JWKS fetching and caching with automatic rotation on `kid` miss.
53#[cfg(feature = "jwks")]
54#[cfg_attr(docsrs, doc(cfg(feature = "jwks")))]
55pub mod jwks;
56
57/// Token revocation: pluggable `TokenRevocationStore` trait with
58/// in-memory and Redis-backed implementations.
59#[cfg(feature = "revocation")]
60#[cfg_attr(docsrs, doc(cfg(feature = "revocation")))]
61pub mod revocation;
62
63// Tests exercise failure paths and invariants directly; unwrap/expect,
64// slicing, and panicking asserts are acceptable here — violations
65// surface as test failures, not production panics.
66#[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    /// REQ-TK-107: `Debug` for `JwtConfig` must redact the signing secret —
305    /// configs get logged, and the secret is the whole game for HMAC JWTs.
306    #[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    /// REQ-TK-101: an expired token must be rejected, not accepted.
319    #[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)), // 1h in the past
329            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    /// REQ-TK-102: `exp` and `iss` are required spec claims — a token
338    /// missing them is rejected even with a valid signature.
339    #[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    /// REQ-TK-103: a token whose `iss` does not match the configured issuer
360    /// is rejected (signed by the same secret — only the issuer differs).
361    #[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    /// REQ-TK-104: a token whose `aud` does not match the configured
381    /// audience is rejected.
382    #[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    /// REQ-TK-105: algorithm pinning — a token signed under a different
402    /// algorithm (here HS384) must be rejected by an HS256 service. This is
403    /// the JWT algorithm-confusion defense.
404    #[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        // `iss` must be present so the ONLY rejection reason can be the
417        // algorithm mismatch: required-spec-claim validation would otherwise
418        // reject the token before the pinned-algorithm check is reached,
419        // masking the very defense under test (REQ-TK-105).
420        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    /// REQ-TK-107: `Debug` for `JwtConfig` surfaces the configured algorithm
432    /// name (operators must be able to tell which alg a config uses without
433    /// un-redacting anything).
434    #[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    /// REQ-TK-003: `encode_standard` stamps `exp` in the future (now + ttl)
450    /// and `iss` from the config — the minted token must validate.
451    #[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    /// REQ-TK-101: `validate` rejects expired tokens and returns the real
469    /// claims for a live token (never default/anonymous claims).
470    #[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    /// REQ-TK-112 (rotation feature): tokens signed with an old secret keep
500    /// verifying while rotation secrets are configured; unknown secrets fail.
501    #[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        // Rotated service: signs with "new-secret", still accepts old.
517        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        // New tokens verify with the new primary secret.
525        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    /// REQ-TK-110/REQ-TK-111 (revocation feature): a revoked `jti` is
535    /// rejected by `decode_standard`; a non-revoked token passes; revocation
536    /// is idempotent and visible across handles of the same store.
537    #[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        // No tokio "macros" feature in this crate's dep set — drive the
544        // async store API through a manually built runtime.
545        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        // decode_standard awaits the revocation store directly; all calls
566        // must run inside the runtime.
567        rt.block_on(async {
568            use super::revocation::TokenRevocationStore as _;
569
570            // Not revoked yet.
571            assert!(service.decode_standard(&token).await.is_ok());
572
573            // Revoke, then verify the check fires from a service sharing the
574            // same store state.
575            store.revoke("jti-to-revoke").await.unwrap();
576            assert!(store.is_revoked("jti-to-revoke").await.unwrap());
577            // Idempotent revoke.
578            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    /// Adapter sharing an `Arc<InMemoryRevocationStore>` with a test.
594    #[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// Tests exercise failure paths and invariants directly; unwrap/expect,
614// slicing, and panicking asserts are acceptable here — violations
615// surface as test failures, not production panics.
616#[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}