jwt_access_verify.rs 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. //! Fuzz RFC 9068 `at+jwt` access-token verification.
  2. //!
  3. //! [`JwtAccessSigner::verify`] checks the JOSE envelope (`typ`, `alg`, `kid`),
  4. //! the signature, `iss`/`exp`, and `aud` containment without touching Redis or
  5. //! Postgres. A first selector byte of `0` replays one fixed, valid token so the
  6. //! accepted branch is exercised deterministically; any other selector treats
  7. //! the remaining bytes as a hostile compact JWT. Random bytes alone are not
  8. //! claimed to reach the accepted branch (that needs a valid signature).
  9. //!
  10. //! Any accepted token must carry the audience the signer was minted for.
  11. #![no_main]
  12. use std::sync::OnceLock;
  13. use d_oauth::{
  14. config::{JwtSigningAlg, JwtSigningKey},
  15. jose::jwt_access::{JwtAccessSigner, JwtClaims},
  16. keys::KeyId,
  17. };
  18. use libfuzzer_sys::fuzz_target;
  19. use secrecy::SecretString;
  20. /// Fixed issuer the signer asserts (never the wall clock or config).
  21. const ISSUER: &str = "https://issuer.fuzz.test";
  22. /// The one audience the fixed token carries.
  23. const AUD: &str = "https://rs.fuzz.test";
  24. /// The fixed signer and its one valid token, built once per process.
  25. struct Fixture {
  26. signer: JwtAccessSigner,
  27. token: String,
  28. aud: Vec<String>,
  29. }
  30. /// Build the fixed ES256 signer and token. `None` when key material or signing
  31. /// fails, which makes the target a no-op instead of panicking on setup.
  32. fn build_fixture() -> Option<Fixture> {
  33. let secret = SecretString::from("C".repeat(32));
  34. let key = JwtSigningKey::from_raw(
  35. KeyId::new("v2"),
  36. JwtSigningAlg::Es256,
  37. &secret,
  38. "fuzz_jwt_signing_key",
  39. )
  40. .ok()?;
  41. let signer = JwtAccessSigner::new(ISSUER.to_owned(), key, Vec::new());
  42. let now = time::OffsetDateTime::now_utc().unix_timestamp();
  43. let aud = vec![AUD.to_owned()];
  44. let claims = JwtClaims {
  45. iss: ISSUER.to_owned(),
  46. sub: "subject@example.test".to_owned(),
  47. aud: aud.clone(),
  48. scope: "read".to_owned(),
  49. exp: now + 86_400,
  50. iat: now,
  51. jti: "fuzz-jti".to_owned(),
  52. client_id: "fuzz-client".to_owned(),
  53. cnf: None,
  54. act: None,
  55. };
  56. let token = signer.sign(&claims).ok()?;
  57. Some(Fixture { signer, token, aud })
  58. }
  59. /// The process-wide fixture, built on first use and reused thereafter.
  60. fn fixture() -> Option<&'static Fixture> {
  61. static FIXTURE: OnceLock<Option<Fixture>> = OnceLock::new();
  62. FIXTURE.get_or_init(build_fixture).as_ref()
  63. }
  64. fuzz_target!(|data: &[u8]| {
  65. let Some(fixture) = fixture() else {
  66. return;
  67. };
  68. let Some((selector, rest)) = data.split_first() else {
  69. return;
  70. };
  71. let token: &str = if *selector == 0 {
  72. fixture.token.as_str()
  73. } else {
  74. match core::str::from_utf8(rest) {
  75. Ok(text) => text,
  76. Err(_) => return,
  77. }
  78. };
  79. // No expected audience: any accepted token must still carry the minted one.
  80. if let Ok(verified) = fixture.signer.verify(token, None) {
  81. assert!(
  82. verified.claims.aud.iter().any(|aud| aud == AUD),
  83. "accepted token lost its expected audience"
  84. );
  85. }
  86. // With an expected audience, acceptance implies containment.
  87. if let Ok(verified) = fixture.signer.verify(token, Some(fixture.aud.as_slice())) {
  88. assert!(
  89. verified.claims.aud.iter().any(|aud| aud == AUD),
  90. "accepted token lost its expected audience"
  91. );
  92. }
  93. });