tests.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. //! `test(client_auth)` pure suite: header parsing, expiry, assertion
  2. //! verification, and the SSRF policy on the `jwks_uri` path (offline).
  3. use std::time::Duration;
  4. use base64::Engine as _;
  5. use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
  6. use ring::rand::SystemRandom;
  7. use ring::signature::{Ed25519KeyPair, KeyPair as _};
  8. use secrecy::ExposeSecret as _;
  9. use serde::{Deserialize, Serialize};
  10. use time::OffsetDateTime;
  11. use super::assertion::{ALLOWED_ALGS, unverified_iss, validate_assertion};
  12. use super::{is_secret_expired, parse_basic_header, token_endpoint};
  13. use crate::protocol::ssrf;
  14. /// This suite's error type (no `unwrap`/`expect` under the deny lints).
  15. type TestResult = Result<(), TestError>;
  16. /// Test scaffolding failures.
  17. #[derive(Debug, thiserror::Error)]
  18. enum TestError {
  19. /// A crypto, encoding, or JWT operation failed.
  20. #[error("test crypto error: {0}")]
  21. Crypto(String),
  22. /// A test assertion observed an unexpected value.
  23. #[error("unexpected test value: {0}")]
  24. Unexpected(String),
  25. }
  26. /// Claims minted by the test signer (mirrors the production shape).
  27. #[derive(Debug, Serialize, Deserialize)]
  28. struct TestClaims {
  29. iss: String,
  30. sub: String,
  31. aud: TestAud,
  32. jti: String,
  33. exp: i64,
  34. iat: i64,
  35. }
  36. /// Single-audience encoding for the happy path.
  37. #[derive(Debug, Serialize, Deserialize)]
  38. #[serde(untagged)]
  39. enum TestAud {
  40. /// Single audience.
  41. One(String),
  42. }
  43. /// An Ed25519 test key plus its public JWK document.
  44. struct TestKey {
  45. encoding: EncodingKey,
  46. jwks: serde_json::Value,
  47. }
  48. impl TestKey {
  49. fn generate(kid: &str) -> Result<Self, TestError> {
  50. let rng = SystemRandom::new();
  51. let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
  52. .map_err(|_| TestError::Crypto("ed25519 keygen failed".to_owned()))?;
  53. let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
  54. .map_err(|_| TestError::Crypto("ed25519 reload failed".to_owned()))?;
  55. let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pair.public_key().as_ref());
  56. let encoding = EncodingKey::from_ed_der(pkcs8.as_ref());
  57. let jwks = serde_json::json!({
  58. "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }]
  59. });
  60. Ok(Self { encoding, jwks })
  61. }
  62. }
  63. /// Mint a signed `private_key_jwt` for `client_id`/`audience`.
  64. fn mint(
  65. key: &TestKey,
  66. client_id: &str,
  67. audience: &str,
  68. header: Header,
  69. skew_secs: i64,
  70. ) -> Result<String, TestError> {
  71. let now = OffsetDateTime::now_utc().unix_timestamp();
  72. let claims = TestClaims {
  73. iss: client_id.to_owned(),
  74. sub: client_id.to_owned(),
  75. aud: TestAud::One(audience.to_owned()),
  76. jti: format!("jti-{client_id}-{now}"),
  77. exp: now + 300 + skew_secs,
  78. iat: now,
  79. };
  80. encode(&header, &claims, &key.encoding)
  81. .map_err(|error| TestError::Crypto(format!("mint failed: {error}")))
  82. }
  83. /// Mint a signed assertion with explicit `exp`/`iat` Unix seconds.
  84. fn mint_at(
  85. key: &TestKey,
  86. client_id: &str,
  87. audience: &str,
  88. header: Header,
  89. exp: i64,
  90. iat: i64,
  91. ) -> Result<String, TestError> {
  92. let claims = TestClaims {
  93. iss: client_id.to_owned(),
  94. sub: client_id.to_owned(),
  95. aud: TestAud::One(audience.to_owned()),
  96. jti: format!("jti-at-{exp}"),
  97. exp,
  98. iat,
  99. };
  100. encode(&header, &claims, &key.encoding)
  101. .map_err(|error| TestError::Crypto(format!("mint failed: {error}")))
  102. }
  103. #[test]
  104. fn client_auth_basic_header_parses_valid() -> TestResult {
  105. let raw = base64::engine::general_purpose::STANDARD.encode("my-client:s3cret:with:colons");
  106. let creds = parse_basic_header(&format!("Basic {raw}"))
  107. .ok_or_else(|| TestError::Unexpected("valid header rejected".to_owned()))?;
  108. if creds.client_id != "my-client" {
  109. return Err(TestError::Unexpected("client_id mismatch".to_owned()));
  110. }
  111. if creds.secret.expose_secret() != "s3cret:with:colons" {
  112. return Err(TestError::Unexpected("secret mismatch".to_owned()));
  113. }
  114. Ok(())
  115. }
  116. #[test]
  117. fn client_auth_basic_header_rejects_malformed() -> TestResult {
  118. let bad = [
  119. "Bearer abc",
  120. "Basic ",
  121. "Basic !!!not-base64!!!",
  122. "Basic bm9jb2xvbg==",
  123. "Basic Om5vLWlk",
  124. ];
  125. for header in bad {
  126. if parse_basic_header(header).is_some() {
  127. return Err(TestError::Unexpected(format!("accepted: {header}")));
  128. }
  129. }
  130. Ok(())
  131. }
  132. #[test]
  133. fn client_auth_secret_expiry_enforced() -> TestResult {
  134. let now = OffsetDateTime::now_utc();
  135. if is_secret_expired(None, now) {
  136. return Err(TestError::Unexpected(
  137. "no expiry must not expire".to_owned(),
  138. ));
  139. }
  140. if !is_secret_expired(Some(now - time::Duration::seconds(1)), now) {
  141. return Err(TestError::Unexpected("past expiry must reject".to_owned()));
  142. }
  143. if is_secret_expired(Some(now + time::Duration::seconds(60)), now) {
  144. return Err(TestError::Unexpected("future expiry must pass".to_owned()));
  145. }
  146. Ok(())
  147. }
  148. #[test]
  149. fn client_auth_token_endpoint_derives_from_issuer() -> TestResult {
  150. let endpoint = token_endpoint("https://auth.example.com");
  151. if endpoint != "https://auth.example.com/token" {
  152. return Err(TestError::Unexpected(endpoint));
  153. }
  154. Ok(())
  155. }
  156. #[test]
  157. fn client_auth_assertion_valid() -> TestResult {
  158. let key = TestKey::generate("k1")?;
  159. let client_id = "client-auth-valid";
  160. let aud = token_endpoint("https://auth.example.com");
  161. let token = mint(&key, client_id, &aud, Header::new(Algorithm::EdDSA), 0)?;
  162. let now = OffsetDateTime::now_utc();
  163. let validated = validate_assertion(&token, &key.jwks, client_id, &aud, now)
  164. .map_err(|error| TestError::Unexpected(format!("valid assertion rejected: {error}")))?;
  165. if validated.jti_hash.is_empty() {
  166. return Err(TestError::Unexpected("empty jti hash".to_owned()));
  167. }
  168. if unverified_iss(&token).as_deref() != Some(client_id) {
  169. return Err(TestError::Unexpected("iss lookup mismatch".to_owned()));
  170. }
  171. Ok(())
  172. }
  173. #[test]
  174. fn client_auth_assertion_wrong_aud_rejected() -> TestResult {
  175. let key = TestKey::generate("k1")?;
  176. let client_id = "client-auth-aud";
  177. let aud = token_endpoint("https://auth.example.com");
  178. let token = mint(
  179. &key,
  180. client_id,
  181. "https://evil.example.com/token",
  182. Header::new(Algorithm::EdDSA),
  183. 0,
  184. )?;
  185. let now = OffsetDateTime::now_utc();
  186. if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
  187. return Err(TestError::Unexpected("wrong aud accepted".to_owned()));
  188. }
  189. Ok(())
  190. }
  191. #[test]
  192. fn client_auth_assertion_wrong_iss_rejected() -> TestResult {
  193. let key = TestKey::generate("k1")?;
  194. let aud = token_endpoint("https://auth.example.com");
  195. let token = mint(&key, "other-client", &aud, Header::new(Algorithm::EdDSA), 0)?;
  196. let now = OffsetDateTime::now_utc();
  197. if validate_assertion(&token, &key.jwks, "client-auth-iss", &aud, now).is_ok() {
  198. return Err(TestError::Unexpected("wrong iss accepted".to_owned()));
  199. }
  200. Ok(())
  201. }
  202. #[test]
  203. fn client_auth_assertion_missing_typ_rejected() -> TestResult {
  204. let key = TestKey::generate("k1")?;
  205. let client_id = "client-auth-typ";
  206. let aud = token_endpoint("https://auth.example.com");
  207. let mut header = Header::new(Algorithm::EdDSA);
  208. header.typ = None;
  209. let token = mint(&key, client_id, &aud, header, 0)?;
  210. let now = OffsetDateTime::now_utc();
  211. if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
  212. return Err(TestError::Unexpected("missing typ accepted".to_owned()));
  213. }
  214. Ok(())
  215. }
  216. #[test]
  217. fn client_auth_assertion_unsigned_rejected() -> TestResult {
  218. let key = TestKey::generate("k1")?;
  219. let client_id = "client-auth-none";
  220. let aud = token_endpoint("https://auth.example.com");
  221. let now = OffsetDateTime::now_utc().unix_timestamp();
  222. let header =
  223. base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
  224. let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
  225. r#"{{"iss":"{client_id}","sub":"{client_id}","aud":"{aud}","jti":"j","exp":{exp},"iat":{now}}}"#,
  226. exp = now + 300,
  227. ));
  228. let token = format!("{header}.{payload}.");
  229. if validate_assertion(
  230. &token,
  231. &key.jwks,
  232. client_id,
  233. &aud,
  234. OffsetDateTime::now_utc(),
  235. )
  236. .is_ok()
  237. {
  238. return Err(TestError::Unexpected(
  239. "unsigned assertion accepted".to_owned(),
  240. ));
  241. }
  242. Ok(())
  243. }
  244. #[test]
  245. fn client_auth_assertion_hmac_rejected() -> TestResult {
  246. use jsonwebtoken::EncodingKey as HmacKey;
  247. let key = TestKey::generate("k1")?;
  248. let client_id = "client-auth-hs";
  249. let aud = token_endpoint("https://auth.example.com");
  250. let now = OffsetDateTime::now_utc().unix_timestamp();
  251. let claims = TestClaims {
  252. iss: client_id.to_owned(),
  253. sub: client_id.to_owned(),
  254. aud: TestAud::One(aud.clone()),
  255. jti: "jti-hs".to_owned(),
  256. exp: now + 300,
  257. iat: now,
  258. };
  259. let token = encode(
  260. &Header::new(Algorithm::HS256),
  261. &claims,
  262. &HmacKey::from_secret(b"client-secret-as-hmac-key"),
  263. )
  264. .map_err(|error| TestError::Crypto(format!("hs mint failed: {error}")))?;
  265. if validate_assertion(
  266. &token,
  267. &key.jwks,
  268. client_id,
  269. &aud,
  270. OffsetDateTime::now_utc(),
  271. )
  272. .is_ok()
  273. {
  274. return Err(TestError::Unexpected("hmac assertion accepted".to_owned()));
  275. }
  276. Ok(())
  277. }
  278. #[test]
  279. fn client_auth_assertion_expired_rejected() -> TestResult {
  280. let key = TestKey::generate("k1")?;
  281. let client_id = "client-auth-exp";
  282. let aud = token_endpoint("https://auth.example.com");
  283. let token = mint(&key, client_id, &aud, Header::new(Algorithm::EdDSA), -600)?;
  284. let now = OffsetDateTime::now_utc();
  285. if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
  286. return Err(TestError::Unexpected(
  287. "expired assertion accepted".to_owned(),
  288. ));
  289. }
  290. Ok(())
  291. }
  292. #[test]
  293. fn client_auth_assertion_long_exp_rejected() -> TestResult {
  294. let key = TestKey::generate("k1")?;
  295. let client_id = "client-auth-long-exp";
  296. let aud = token_endpoint("https://auth.example.com");
  297. let now = OffsetDateTime::now_utc();
  298. let now_secs = now.unix_timestamp();
  299. let token = mint_at(
  300. &key,
  301. client_id,
  302. &aud,
  303. Header::new(Algorithm::EdDSA),
  304. now_secs + 3600,
  305. now_secs,
  306. )?;
  307. if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
  308. return Err(TestError::Unexpected("far-future exp accepted".to_owned()));
  309. }
  310. Ok(())
  311. }
  312. #[test]
  313. fn client_auth_assertion_exp_before_iat_rejected() -> TestResult {
  314. let key = TestKey::generate("k1")?;
  315. let client_id = "client-auth-neg-lifetime";
  316. let aud = token_endpoint("https://auth.example.com");
  317. let now = OffsetDateTime::now_utc();
  318. let now_secs = now.unix_timestamp();
  319. let token = mint_at(
  320. &key,
  321. client_id,
  322. &aud,
  323. Header::new(Algorithm::EdDSA),
  324. now_secs - 10,
  325. now_secs + 25,
  326. )?;
  327. if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
  328. return Err(TestError::Unexpected("exp before iat accepted".to_owned()));
  329. }
  330. Ok(())
  331. }
  332. #[test]
  333. fn client_auth_assertion_short_exp_accepted() -> TestResult {
  334. let key = TestKey::generate("k1")?;
  335. let client_id = "client-auth-short-exp";
  336. let aud = token_endpoint("https://auth.example.com");
  337. let now = OffsetDateTime::now_utc();
  338. let now_secs = now.unix_timestamp();
  339. let token = mint_at(
  340. &key,
  341. client_id,
  342. &aud,
  343. Header::new(Algorithm::EdDSA),
  344. now_secs + 60,
  345. now_secs,
  346. )?;
  347. validate_assertion(&token, &key.jwks, client_id, &aud, now)
  348. .map_err(|error| TestError::Unexpected(format!("short-lived assertion rejected: {error}")))?;
  349. Ok(())
  350. }
  351. #[test]
  352. fn client_auth_assertion_jti_ttl_matches_remaining_validity() -> TestResult {
  353. let key = TestKey::generate("k1")?;
  354. let client_id = "client-auth-ttl";
  355. let aud = token_endpoint("https://auth.example.com");
  356. let now_secs = OffsetDateTime::now_utc().unix_timestamp();
  357. let now = OffsetDateTime::from_unix_timestamp(now_secs)
  358. .map_err(|error| TestError::Unexpected(format!("bad test timestamp: {error}")))?;
  359. let token = mint_at(
  360. &key,
  361. client_id,
  362. &aud,
  363. Header::new(Algorithm::EdDSA),
  364. now_secs + 60,
  365. now_secs,
  366. )?;
  367. let validated = validate_assertion(&token, &key.jwks, client_id, &aud, now)
  368. .map_err(|error| TestError::Unexpected(format!("assertion rejected: {error}")))?;
  369. if validated.jti_ttl != Duration::from_secs(90) {
  370. return Err(TestError::Unexpected(format!(
  371. "jti_ttl {}s != 90s (60s remaining + 30s skew)",
  372. validated.jti_ttl.as_secs()
  373. )));
  374. }
  375. Ok(())
  376. }
  377. #[test]
  378. fn client_auth_assertion_jti_ttl_bounded_by_cap() -> TestResult {
  379. let key = TestKey::generate("k1")?;
  380. let client_id = "client-auth-ttl-cap";
  381. let aud = token_endpoint("https://auth.example.com");
  382. let now_secs = OffsetDateTime::now_utc().unix_timestamp();
  383. let now = OffsetDateTime::from_unix_timestamp(now_secs)
  384. .map_err(|error| TestError::Unexpected(format!("bad test timestamp: {error}")))?;
  385. let token = mint_at(
  386. &key,
  387. client_id,
  388. &aud,
  389. Header::new(Algorithm::EdDSA),
  390. now_secs + 300,
  391. now_secs,
  392. )?;
  393. let validated = validate_assertion(&token, &key.jwks, client_id, &aud, now)
  394. .map_err(|error| TestError::Unexpected(format!("assertion rejected: {error}")))?;
  395. if validated.jti_ttl != Duration::from_secs(330) {
  396. return Err(TestError::Unexpected(format!(
  397. "jti_ttl {}s != 330s (300s cap + 30s skew)",
  398. validated.jti_ttl.as_secs()
  399. )));
  400. }
  401. if validated.jti_ttl > Duration::from_secs(360) {
  402. return Err(TestError::Unexpected(
  403. "jti_ttl exceeds the 300s + 2*30s clamp".to_owned(),
  404. ));
  405. }
  406. Ok(())
  407. }
  408. #[test]
  409. fn client_auth_allowed_algs_include_ps256() -> TestResult {
  410. if !ALLOWED_ALGS.contains(&Algorithm::PS256) {
  411. return Err(TestError::Unexpected("PS256 not allowed".to_owned()));
  412. }
  413. Ok(())
  414. }
  415. #[test]
  416. fn client_auth_allowed_algs_match_metadata_advertisement() -> TestResult {
  417. let mut advertised: Vec<String> = crate::protocol::metadata::CLIENT_ASSERTION_SIGNING_ALG_VALUES
  418. .iter()
  419. .map(|alg| (*alg).to_owned())
  420. .collect();
  421. advertised.sort();
  422. let mut allowed: Vec<String> = ALLOWED_ALGS.iter().map(|alg| format!("{alg:?}")).collect();
  423. allowed.sort();
  424. if allowed != advertised {
  425. return Err(TestError::Unexpected(format!(
  426. "allowlist {allowed:?} != metadata {advertised:?}"
  427. )));
  428. }
  429. Ok(())
  430. }
  431. #[test]
  432. fn client_auth_jwks_uri_private_ip_rejected_by_guard() -> TestResult {
  433. if ssrf::validate_fetch_url("https://127.0.0.1:8443/jwks.json").is_err() {
  434. return Err(TestError::Unexpected(
  435. "policy pass expected; target block happens at DNS step".to_owned(),
  436. ));
  437. }
  438. Ok(())
  439. }