jar.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  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 support::{CLIENT_ID, ISSUER, KEY_ID, TestError, TestResult, code_of, store_conn, test_client};
  16. use time::OffsetDateTime;
  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.params.get("scope").map(String::as_str) != Some("read")
  96. || merged.params.get("redirect_uri").map(String::as_str)
  97. != Some("https://client.example.test/cb")
  98. || merged.params.get("state").map(String::as_str) != Some("xyz")
  99. {
  100. return Err(TestError::Unexpected("merged params mismatch".to_owned()));
  101. }
  102. Ok(())
  103. }
  104. #[tokio::test]
  105. async fn jar_bad_signature_rejected() -> TestResult {
  106. let (_redis, mut conn, store) = store_conn().await?;
  107. let client = test_client("client_secret_basic", None);
  108. let jwt = sign(OTHER_KEY_B64, Some(KEY_ID), &base_claims(json!({})))?;
  109. let query = HashMap::new();
  110. let input = JarRequest {
  111. client: &client,
  112. issuer: ISSUER,
  113. query: &query,
  114. request: Some(&jwt),
  115. request_uri: None,
  116. };
  117. let outcome = resolve(&mut conn, &store, &input).await;
  118. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  119. return Err(TestError::Unexpected("bad signature accepted".to_owned()));
  120. }
  121. Ok(())
  122. }
  123. #[tokio::test]
  124. async fn jar_wrong_aud_rejected() -> TestResult {
  125. let (_redis, mut conn, store) = store_conn().await?;
  126. let client = test_client("client_secret_basic", None);
  127. let jwt = sign(
  128. CLIENT_KEY_B64,
  129. Some(KEY_ID),
  130. &base_claims(json!({"aud": "https://other.example.test"})),
  131. )?;
  132. let query = HashMap::new();
  133. let input = JarRequest {
  134. client: &client,
  135. issuer: ISSUER,
  136. query: &query,
  137. request: Some(&jwt),
  138. request_uri: None,
  139. };
  140. let outcome = resolve(&mut conn, &store, &input).await;
  141. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  142. return Err(TestError::Unexpected("wrong aud accepted".to_owned()));
  143. }
  144. Ok(())
  145. }
  146. #[tokio::test]
  147. async fn jar_expired_rejected() -> TestResult {
  148. let (_redis, mut conn, store) = store_conn().await?;
  149. let client = test_client("client_secret_basic", None);
  150. let now = OffsetDateTime::now_utc().unix_timestamp();
  151. let jwt = sign(
  152. CLIENT_KEY_B64,
  153. Some(KEY_ID),
  154. &base_claims(json!({"exp": now - 3600})),
  155. )?;
  156. let query = HashMap::new();
  157. let input = JarRequest {
  158. client: &client,
  159. issuer: ISSUER,
  160. query: &query,
  161. request: Some(&jwt),
  162. request_uri: None,
  163. };
  164. let outcome = resolve(&mut conn, &store, &input).await;
  165. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  166. return Err(TestError::Unexpected("expired object accepted".to_owned()));
  167. }
  168. Ok(())
  169. }
  170. #[tokio::test]
  171. async fn jar_alg_none_rejected() -> TestResult {
  172. use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
  173. let (_redis, mut conn, store) = store_conn().await?;
  174. let client = test_client("client_secret_basic", None);
  175. let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
  176. let body = URL_SAFE_NO_PAD.encode(r#"{"iss":"jar-test-client"}"#);
  177. let jwt = format!("{header}.{body}.");
  178. let query = HashMap::new();
  179. let input = JarRequest {
  180. client: &client,
  181. issuer: ISSUER,
  182. query: &query,
  183. request: Some(&jwt),
  184. request_uri: None,
  185. };
  186. let outcome = resolve(&mut conn, &store, &input).await;
  187. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  188. return Err(TestError::Unexpected("alg:none accepted".to_owned()));
  189. }
  190. Ok(())
  191. }
  192. #[tokio::test]
  193. async fn jar_conflicting_duplicates_rejected() -> TestResult {
  194. let (_redis, mut conn, store) = store_conn().await?;
  195. let client = test_client("client_secret_basic", None);
  196. let jwt = sign(CLIENT_KEY_B64, Some(KEY_ID), &base_claims(json!({})))?;
  197. let mut query = HashMap::new();
  198. query.insert("scope".to_owned(), "write".to_owned());
  199. let input = JarRequest {
  200. client: &client,
  201. issuer: ISSUER,
  202. query: &query,
  203. request: Some(&jwt),
  204. request_uri: None,
  205. };
  206. let outcome = resolve(&mut conn, &store, &input).await;
  207. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequestObject) {
  208. return Err(TestError::Unexpected(
  209. "conflicting duplicate accepted".to_owned(),
  210. ));
  211. }
  212. Ok(())
  213. }
  214. #[tokio::test]
  215. async fn jar_confidential_client_without_par_or_request_rejected() -> TestResult {
  216. let (_redis, mut conn, store) = store_conn().await?;
  217. let client = test_client("client_secret_basic", None);
  218. let mut query = HashMap::new();
  219. query.insert("client_id".to_owned(), CLIENT_ID.to_owned());
  220. query.insert("response_type".to_owned(), "code".to_owned());
  221. let input = JarRequest {
  222. client: &client,
  223. issuer: ISSUER,
  224. query: &query,
  225. request: None,
  226. request_uri: None,
  227. };
  228. let outcome = resolve(&mut conn, &store, &input).await;
  229. if code_of(&outcome) != Some(OAuthErrorCode::InvalidRequest) {
  230. return Err(TestError::Unexpected(
  231. "plain query accepted for confidential client".to_owned(),
  232. ));
  233. }
  234. Ok(())
  235. }
  236. #[tokio::test]
  237. async fn jar_public_client_without_request_object_uses_query() -> TestResult {
  238. let (_redis, mut conn, store) = store_conn().await?;
  239. let client = test_client("none", None);
  240. let mut query = HashMap::new();
  241. query.insert("client_id".to_owned(), CLIENT_ID.to_owned());
  242. query.insert("response_type".to_owned(), "code".to_owned());
  243. let input = JarRequest {
  244. client: &client,
  245. issuer: ISSUER,
  246. query: &query,
  247. request: None,
  248. request_uri: None,
  249. };
  250. let merged = resolve(&mut conn, &store, &input)
  251. .await
  252. .map_err(|error| TestError::Unexpected(error.to_string()))?;
  253. if merged.params != query {
  254. return Err(TestError::Unexpected(
  255. "public query passthrough altered".to_owned(),
  256. ));
  257. }
  258. if !merged.resource.is_empty() {
  259. return Err(TestError::Unexpected(
  260. "plain query must pin no resource".to_owned(),
  261. ));
  262. }
  263. Ok(())
  264. }