jar.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. //! `test(jar)` suite, part 1: inline signed request objects (RFC 9101).
  2. //!
  3. //! Valid objects merge over the query; bad signature, wrong `aud`,
  4. //! expiry, `alg:none`, conflicting duplicates, and the JAR/PAR-mandatory
  5. //! rule are covered here. `request_uri` paths live in `jar_request_uri.rs`.
  6. #[path = "jar_support.rs"]
  7. mod support;
  8. use std::collections::HashMap;
  9. use d_oauth::{
  10. error::OAuthErrorCode,
  11. jose::jar::{JarRequest, resolve},
  12. };
  13. use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
  14. use serde_json::{Value, json};
  15. use time::OffsetDateTime;
  16. use support::{CLIENT_ID, ISSUER, KEY_ID, TestError, TestResult, code_of, store_conn, test_client};
  17. /// PKCS#8 DER (base64) of the client's RSA signing key (test-only fixture).
  18. const CLIENT_KEY_B64: &str = concat!(
  19. "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCzwV9fZoba06PGlNq2GXjZ4dZaQDALF6zUCp6kDARqV++kJ43c7u6uULPOweopNeiGk17R8xKk1CMyMbvkbMyLwOmN0ZvxKq+dH1Q/bX6l5OirR26jx8G+vFsGsuRz/zOV",
  20. "B5PgQ3REhxP6QnxS7rwBcD2QLGO3bBZXLufpXTpiF+IIKwiSdzjdO6ccTZVABCi8LQrENBt/GxmGtA8u5z44SirZohoYOu3LYbROp21TtpfOZgMcyCYOUb+g9A0DNWMc2Xn7645RPdRYQXWAXUG+y+nYWqxevOB5v9SaUYFmnxp23oYzUxrX",
  21. "gShJhMTAxhgIthmaT0vnrKRPRGvKQcRhAgMBAAECggEAWUTOmt+dWB4jpvJ0nVuQB1Be9coLse6Lf3BYsWpgZPmPwqt8CbzE3t+oNCVcJyQk+nREcipvV1kcs4g8YefgOwVOV4ZBleaXeQW+fpNfgn5oR+s9GGwTHSiIYg7EYpDZ6ohtWUHW",
  22. "g1O7yZxsBnmV93bos+k/ii9M9vYN6PhKDr0qlX+NKtSZkUxWDvnsfTizkocEBKMGBHLNm6g4KS7N8bClzbgwxCAMLzNtT+/rKEvb6/dQhuSIiVeRlDSDvCaTBdSEqajcoWsrTpzkplb/KbAT8Rkin1KnlQ0s8NHZaC3R/cddvvfJZYgOBDhq",
  23. "WPW7qNGdzeqb6pevZyS4+LjrcwKBgQD9ZLEo0AlJj9wm243JH0Ob2ubi/wnifa28vg4pEvEXbYhQbLJBJluxn3fQ4LuWObiNwo0SpagOmLScjpqV6mr9/U7/f9BldrKrtPsYDM/JzwzBSox+HgBUvvIgT6Gm3PwCbGfqyLnivFj5PfEoQQ+D",
  24. "tq+oQjFnnU3gUGo4BGl7QwKBgQC1msGDZdYr4mmaBLXwIsWqJHQ7adogF16xSPRGGUONena5bhrhcPGAKRG+XxCrpV0vQZZ2cY5VXv/K80+RFm+z1fEI+FENiLTxfkj0aE+5FRxViEl6LDJy8oGUk9DSIglcrAd6DzUA9RZnLiqapsP/TQxH",
  25. "Mh1NmzZ1QO9OD3HdiwKBgQCAb0Mkjp3GfXZ2853gLUqVetTAhci61jzzh9mhlApDd686JH4Na6XaWBvg8afjMwqKP+xFYVXXYCtblyRfjFwCtRwPgPdeFlXllmwW+pdvkNTEPEcNvaMpUUdkfThQXX4WtF/Pr0IY3KK0YmMTpjZ46mYKgNKN",
  26. "1Due0OOl1QaqBQKBgQCqftge3ITuPmqWRsXo5TxeOEtHXGGEr2wfVTbAeQeK0+aSwGRMMxUIFYarC+VCwMVzkEgKeyH91Gqhxzx0InRMihb5LOgrpKT79GvZyfTO9prbUlBpewUCFdrB+uSZ44qWjouWdKB2faLCy/0DpH1uOo/EZ7BLI+VE",
  27. "v+nrh2SHcQKBgQD0LMmasHOwG1SFsIObaquSVb6ETbloZ4J1OmpeUrMJEN9TcpY6KbzSrqXl0npSziiUTCE8nQgqDyqCTduhswD413irGN2vIkyfrR2pqBlLOF+INfH0HUQQZLiH6P4L+sFNbeizpHT9qLa5KtBBs9UD40kfZjUPqYMmgA6J",
  28. "DNOf1Q==",
  29. );
  30. /// PKCS#8 DER (base64) of a foreign RSA key: signatures under it must fail.
  31. const OTHER_KEY_B64: &str = concat!(
  32. "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDNSLFGmYGgzFFpJ+Pj2p6aTttNl2vG2YRTvfKd45WYGX044GJx8zHwIQuOCSGFtLW92HOdXxsU81na/BXPwIXI4cw97D+y5ufIEo9toJmUcyaPF6JFWLKcqgoJqrci520/",
  33. "1ovafGYBqL+JyjSKsvZHRH38nHtlzLiPU7fS1NMW1RAnd9smyRRKUK0tawFx+dXyrACuppNFWHGHJZDKsRN8t0z2SOx4RjhLHOGZzw1b9/gJA0ag+1GK4NnzemCd62TM5wSrgXw5IEEJ2NqKfRk54uZCM26DD9moZV3xrqI8OBL8dLbsFie6",
  34. "/zaxC9UTV0A54h66mxXYhGLdC6tdyNV1AgMBAAECggEAGASaBzBVKA7XtJWv9lwBA/1Xb8HYYIzZDGI0NXBn84KB8epg4kOps1Zub2kWlLnyrJpbVo2G0eHyAKrPU34LORhgCNuwDTqyYih3wwQQC/wYBhRmDjaFOUq0v3JLmIgasmYj+UVm",
  35. "Oun3NQQlNxges/Jpyik1z9COxGgvYPm5E9nWo+eWSj/iz+slp14KVb/YKtbt0kBtqNKlH8SR8g0JRbt2M7Vs/7bMFX2NqzeiSsj3re73+3abSx1p76bj6YNqCW4nYy/7dVawP8V4f8uXM4TkwexMWqP82PDP01zsEuiKVxFVcF/ogOZ9ZtjX",
  36. "rRdeTBaOeTDCJVSgqwL3IlXN0QKBgQDwpKR2BADW/2JntB17Bu67g48jRz7xJ54C8fV2ZV7hW6ytFVTUH6LdjK5OR/1oiqZG0lyQcgdDTuUoqdVcphjbnJHt8RNMAG9JkehuUoQq6xPAmYZHs72450kFuQgAKoaDCqOnbHdsHlmpN/hMZmvK",
  37. "ulOF4bJoZK/7oYlQ8A0JhQKBgQDaYmQ2ExfML3rgZ2BIFYSEMDFIordSWOENvVIIkDTuFiqWBiRSzS5IZY1gq+ToVh0rALGMZN6lb0y+v6WdRAqbY8IPcmlb8FZvnq1MXge0Xr+DUHvdHo1uZ9RlXqlfdc7M9pwzdQ7smK6oNnQctrvjBkBy",
  38. "Aq0WhF85h6jX1dfnMQKBgQCQENFQwfbKwBvmn+MiWzUjV5Ci+y1IbIJhmv9sASY7XonTGTkuwT3cyzMzNFAWfSMstRWreEi22A3DOWG91MZ++4412CTIVF5LuilfmtD7bL7niEHmvUmw3+5MmcibaNocq4rsWbhpJwZgk1x4SLzxonkMZuFL",
  39. "NDRTPhMaPIxhjQKBgQDFQ8Ffsyl48J6fG+I12vID8gBiq1DLGqvYHF6Ai7q0IeO/9rZS8TJQhSMK4tr4H+ljEdJPjkIrz/jUCQuRnoeyS49dIq0XNmbtlrjV1c5B2KBH/qiUWWfKBhIuqy51o1X1gQIh3ShpAZYrLlz+HvZjIR+PnOk7AMF/",
  40. "lxImPO000QKBgQDEwUbOROo7xR5rIB2CFTeg9HK/UyC3EYPZvqq6HBMSRQv6H3eOpu1Oro/5G/AzA9TbFJDUtlvDT+RPxk7uWTjtqoUZpeokpLRpgWhJZNlZUuFmEXZlObrHN4TtLiusU1B4FhCJznUOEpPXkQ02ZpJCV6/vW8MNcMKBUzLp",
  41. "2J8sJQ==",
  42. );
  43. /// Sign `claims` with the given fixture key under RS256.
  44. fn sign(key_b64: &str, kid: Option<&str>, claims: &Value) -> Result<String, TestError> {
  45. use base64::{Engine as _, engine::general_purpose::STANDARD};
  46. let der = STANDARD
  47. .decode(key_b64)
  48. .map_err(|_| TestError::Unexpected("fixture key is not decodable".to_owned()))?;
  49. let pem = format!(
  50. "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
  51. STANDARD.encode(&der)
  52. );
  53. let mut header = Header::new(Algorithm::RS256);
  54. header.kid = kid.map(str::to_owned);
  55. Ok(encode(
  56. &header,
  57. claims,
  58. &EncodingKey::from_rsa_pem(pem.as_bytes())?,
  59. )?)
  60. }
  61. /// Base request-object claims bound to this suite's client and issuer.
  62. fn base_claims(extra: Value) -> Value {
  63. let now = OffsetDateTime::now_utc().unix_timestamp();
  64. let mut claims = json!({
  65. "iss": CLIENT_ID, "aud": ISSUER, "exp": now + 300,
  66. "response_type": "code", "client_id": CLIENT_ID,
  67. "redirect_uri": "https://client.example.test/cb",
  68. "scope": "read", "state": "xyz",
  69. });
  70. if let (Some(into), Some(from)) = (claims.as_object_mut(), extra.as_object()) {
  71. for (key, value) in from {
  72. into.insert(key.clone(), value.clone());
  73. }
  74. }
  75. claims
  76. }
  77. #[tokio::test]
  78. async fn jar_valid_signed_request_merged() -> TestResult {
  79. let (_redis, mut conn, store) = store_conn().await?;
  80. let client = test_client("client_secret_basic", None);
  81. let jwt = sign(CLIENT_KEY_B64, Some(KEY_ID), &base_claims(json!({})))?;
  82. let mut query = HashMap::new();
  83. query.insert("client_id".to_owned(), CLIENT_ID.to_owned());
  84. query.insert("state".to_owned(), "xyz".to_owned());
  85. let input = JarRequest {
  86. client: &client,
  87. issuer: ISSUER,
  88. query: &query,
  89. request: Some(&jwt),
  90. request_uri: None,
  91. };
  92. let merged = resolve(&mut conn, &store, &input)
  93. .await
  94. .map_err(|error| TestError::Unexpected(error.to_string()))?;
  95. if merged.get("scope").map(String::as_str) != Some("read")
  96. || merged.get("redirect_uri").map(String::as_str) != Some("https://client.example.test/cb")
  97. || merged.get("state").map(String::as_str) != Some("xyz")
  98. {
  99. return Err(TestError::Unexpected("merged params mismatch".to_owned()));
  100. }
  101. Ok(())
  102. }
  103. #[tokio::test]
  104. async fn jar_bad_signature_rejected() -> TestResult {
  105. let (_redis, mut conn, store) = store_conn().await?;
  106. let client = test_client("client_secret_basic", None);
  107. let jwt = sign(OTHER_KEY_B64, Some(KEY_ID), &base_claims(json!({})))?;
  108. let query = HashMap::new();
  109. let input = JarRequest {
  110. client: &client,
  111. issuer: ISSUER,
  112. query: &query,
  113. request: Some(&jwt),
  114. request_uri: None,
  115. };
  116. let outcome = resolve(&mut conn, &store, &input).await;
  117. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  118. return Err(TestError::Unexpected("bad signature accepted".to_owned()));
  119. }
  120. Ok(())
  121. }
  122. #[tokio::test]
  123. async fn jar_wrong_aud_rejected() -> TestResult {
  124. let (_redis, mut conn, store) = store_conn().await?;
  125. let client = test_client("client_secret_basic", None);
  126. let jwt = sign(
  127. CLIENT_KEY_B64,
  128. Some(KEY_ID),
  129. &base_claims(json!({"aud": "https://other.example.test"})),
  130. )?;
  131. let query = HashMap::new();
  132. let input = JarRequest {
  133. client: &client,
  134. issuer: ISSUER,
  135. query: &query,
  136. request: Some(&jwt),
  137. request_uri: None,
  138. };
  139. let outcome = resolve(&mut conn, &store, &input).await;
  140. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  141. return Err(TestError::Unexpected("wrong aud accepted".to_owned()));
  142. }
  143. Ok(())
  144. }
  145. #[tokio::test]
  146. async fn jar_expired_rejected() -> TestResult {
  147. let (_redis, mut conn, store) = store_conn().await?;
  148. let client = test_client("client_secret_basic", None);
  149. let now = OffsetDateTime::now_utc().unix_timestamp();
  150. let jwt = sign(
  151. CLIENT_KEY_B64,
  152. Some(KEY_ID),
  153. &base_claims(json!({"exp": now - 3600})),
  154. )?;
  155. let query = HashMap::new();
  156. let input = JarRequest {
  157. client: &client,
  158. issuer: ISSUER,
  159. query: &query,
  160. request: Some(&jwt),
  161. request_uri: None,
  162. };
  163. let outcome = resolve(&mut conn, &store, &input).await;
  164. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  165. return Err(TestError::Unexpected("expired object accepted".to_owned()));
  166. }
  167. Ok(())
  168. }
  169. #[tokio::test]
  170. async fn jar_alg_none_rejected() -> TestResult {
  171. use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
  172. let (_redis, mut conn, store) = store_conn().await?;
  173. let client = test_client("client_secret_basic", None);
  174. let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
  175. let body = URL_SAFE_NO_PAD.encode(r#"{"iss":"jar-test-client"}"#);
  176. let jwt = format!("{header}.{body}.");
  177. let query = HashMap::new();
  178. let input = JarRequest {
  179. client: &client,
  180. issuer: ISSUER,
  181. query: &query,
  182. request: Some(&jwt),
  183. request_uri: None,
  184. };
  185. let outcome = resolve(&mut conn, &store, &input).await;
  186. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  187. return Err(TestError::Unexpected("alg:none accepted".to_owned()));
  188. }
  189. Ok(())
  190. }
  191. #[tokio::test]
  192. async fn jar_conflicting_duplicates_rejected() -> TestResult {
  193. let (_redis, mut conn, store) = store_conn().await?;
  194. let client = test_client("client_secret_basic", None);
  195. let jwt = sign(CLIENT_KEY_B64, Some(KEY_ID), &base_claims(json!({})))?;
  196. let mut query = HashMap::new();
  197. query.insert("scope".to_owned(), "write".to_owned());
  198. let input = JarRequest {
  199. client: &client,
  200. issuer: ISSUER,
  201. query: &query,
  202. request: Some(&jwt),
  203. request_uri: None,
  204. };
  205. let outcome = resolve(&mut conn, &store, &input).await;
  206. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  207. return Err(TestError::Unexpected(
  208. "conflicting duplicate accepted".to_owned(),
  209. ));
  210. }
  211. Ok(())
  212. }
  213. #[tokio::test]
  214. async fn jar_confidential_client_without_par_or_request_rejected() -> TestResult {
  215. let (_redis, mut conn, store) = store_conn().await?;
  216. let client = test_client("client_secret_basic", None);
  217. let mut query = HashMap::new();
  218. query.insert("client_id".to_owned(), CLIENT_ID.to_owned());
  219. query.insert("response_type".to_owned(), "code".to_owned());
  220. let input = JarRequest {
  221. client: &client,
  222. issuer: ISSUER,
  223. query: &query,
  224. request: None,
  225. request_uri: None,
  226. };
  227. let outcome = resolve(&mut conn, &store, &input).await;
  228. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequest) {
  229. return Err(TestError::Unexpected(
  230. "plain query accepted for confidential client".to_owned(),
  231. ));
  232. }
  233. Ok(())
  234. }
  235. #[tokio::test]
  236. async fn jar_public_client_without_request_object_uses_query() -> TestResult {
  237. let (_redis, mut conn, store) = store_conn().await?;
  238. let client = test_client("none", None);
  239. let mut query = HashMap::new();
  240. query.insert("client_id".to_owned(), CLIENT_ID.to_owned());
  241. query.insert("response_type".to_owned(), "code".to_owned());
  242. let input = JarRequest {
  243. client: &client,
  244. issuer: ISSUER,
  245. query: &query,
  246. request: None,
  247. request_uri: None,
  248. };
  249. let merged = resolve(&mut conn, &store, &input)
  250. .await
  251. .map_err(|error| TestError::Unexpected(error.to_string()))?;
  252. if merged != query {
  253. return Err(TestError::Unexpected(
  254. "public query passthrough altered".to_owned(),
  255. ));
  256. }
  257. Ok(())
  258. }