consume.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. //! Push-to-consume PAR tests: one-time records, authorize-side client
  2. //! binding, replay rejection, and TTL expiry.
  3. use std::{collections::HashMap, time::Duration};
  4. use super::TestError;
  5. use super::{
  6. super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
  7. insert_client, push_params, stores_of,
  8. };
  9. use crate::{
  10. db::{TokenEndpointAuthMethod, client_repo},
  11. error::OAuthErrorCode,
  12. jose::jar::{JarRequest, PAR_REQUEST_URI_PREFIX, resolve},
  13. store::par,
  14. };
  15. /// Push via a public client and return the issued `request_uri`.
  16. async fn push_public(
  17. h: &mut super::Harness,
  18. endpoint: &str,
  19. client_id: &str,
  20. redirect_uri: &str,
  21. ) -> Result<String, TestError> {
  22. let params = push_params(client_id, redirect_uri);
  23. let auth = bare_auth(client_id);
  24. let ctx = ctx_of(endpoint);
  25. let mut stores = stores_of(h);
  26. let response = push_authorization_request(&mut stores, &ctx, &auth, &params, &[])
  27. .await
  28. .map_err(|error| TestError::Unexpected(format!("push failed: {}", error.code())))?;
  29. if response.expires_in != 90 {
  30. return Err(TestError::Unexpected(format!(
  31. "wrong expires_in: {}",
  32. response.expires_in
  33. )));
  34. }
  35. if !response.request_uri.starts_with(PAR_REQUEST_URI_PREFIX) {
  36. return Err(TestError::Unexpected(format!(
  37. "bad request_uri: {}",
  38. response.request_uri
  39. )));
  40. }
  41. Ok(response.request_uri)
  42. }
  43. #[tokio::test]
  44. async fn par_push_stores_one_time_record() -> Result<(), TestError> {
  45. let mut h = harness().await?;
  46. let client_id = "par-public-client";
  47. let redirect_uri = "https://client.example.test/cb";
  48. insert_client(
  49. &h.pool,
  50. &h.keys,
  51. client_id,
  52. TokenEndpointAuthMethod::None,
  53. None,
  54. )
  55. .await?;
  56. attach_redirect(&h.pool, &h.keys, client_id, redirect_uri).await?;
  57. let endpoint = h.endpoint.clone();
  58. let request_uri = push_public(&mut h, &endpoint, client_id, redirect_uri).await?;
  59. let Some(id) = request_uri.strip_prefix(PAR_REQUEST_URI_PREFIX) else {
  60. return Err(TestError::Unexpected("missing PAR prefix".to_owned()));
  61. };
  62. let record = par::consume(&mut h.conn, &h.store, id)
  63. .await
  64. .map_err(|error| TestError::Unexpected(format!("{error}")))?;
  65. let Some(record) = record else {
  66. return Err(TestError::Unexpected("PAR record missing".to_owned()));
  67. };
  68. if record.client_id != client_id {
  69. return Err(TestError::Unexpected("PAR record wrong client".to_owned()));
  70. }
  71. let stored: serde_json::Value = serde_json::from_str(&record.request)
  72. .map_err(|error| TestError::Unexpected(format!("{error}")))?;
  73. if stored.get("redirect_uri").and_then(|value| value.as_str()) != Some(redirect_uri) {
  74. return Err(TestError::Unexpected("stored redirect_uri lost".to_owned()));
  75. }
  76. if stored.get("client_secret").is_some() {
  77. return Err(TestError::Unexpected(
  78. "credential leaked into store".to_owned(),
  79. ));
  80. }
  81. let replayed = par::consume(&mut h.conn, &h.store, id)
  82. .await
  83. .map_err(|error| TestError::Unexpected(format!("{error}")))?;
  84. if replayed.is_some() {
  85. return Err(TestError::Unexpected("PAR record reusable".to_owned()));
  86. }
  87. Ok(())
  88. }
  89. #[tokio::test]
  90. async fn par_authorize_binds_client_and_rejects_replay() -> Result<(), TestError> {
  91. let mut h = harness().await?;
  92. let client_a = "par-client-a";
  93. let client_b = "par-client-b";
  94. let redirect_uri = "https://client.example.test/cb";
  95. insert_client(
  96. &h.pool,
  97. &h.keys,
  98. client_a,
  99. TokenEndpointAuthMethod::None,
  100. None,
  101. )
  102. .await?;
  103. insert_client(
  104. &h.pool,
  105. &h.keys,
  106. client_b,
  107. TokenEndpointAuthMethod::None,
  108. None,
  109. )
  110. .await?;
  111. attach_redirect(&h.pool, &h.keys, client_a, redirect_uri).await?;
  112. attach_redirect(&h.pool, &h.keys, client_b, redirect_uri).await?;
  113. let endpoint = h.endpoint.clone();
  114. let request_uri = push_public(&mut h, &endpoint, client_a, redirect_uri).await?;
  115. let issuer = "https://auth.example.test";
  116. let client_row_b = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_b)
  117. .await?
  118. .ok_or_else(|| TestError::Unexpected("client B missing".to_owned()))?;
  119. let query_b = HashMap::from([
  120. ("client_id".to_owned(), client_b.to_owned()),
  121. ("request_uri".to_owned(), request_uri.clone()),
  122. ]);
  123. let foreign = JarRequest {
  124. client: &client_row_b,
  125. issuer,
  126. query: &query_b,
  127. request: None,
  128. request_uri: Some(request_uri.as_str()),
  129. };
  130. match resolve(&mut h.conn, &h.store, &foreign).await {
  131. Ok(_) => {
  132. return Err(TestError::Unexpected(
  133. "cross-client request_uri accepted".to_owned(),
  134. ));
  135. }
  136. Err(error) => expect_code(&error, OAuthErrorCode::InvalidRequest)?,
  137. }
  138. // The foreign attempt must not burn the victim record: the legitimate
  139. // first use resolves the same `request_uri`.
  140. let client_row_a = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_a)
  141. .await?
  142. .ok_or_else(|| TestError::Unexpected("client A missing".to_owned()))?;
  143. let query_a = HashMap::from([
  144. ("client_id".to_owned(), client_a.to_owned()),
  145. ("request_uri".to_owned(), request_uri.clone()),
  146. ]);
  147. let first = JarRequest {
  148. client: &client_row_a,
  149. issuer,
  150. query: &query_a,
  151. request: None,
  152. request_uri: Some(request_uri.as_str()),
  153. };
  154. let merged = resolve(&mut h.conn, &h.store, &first)
  155. .await
  156. .map_err(|error| TestError::Unexpected(format!("first use failed: {}", error.code())))?;
  157. if merged.params.get("redirect_uri").map(String::as_str) != Some(redirect_uri) {
  158. return Err(TestError::Unexpected("merged params lost".to_owned()));
  159. }
  160. let replay = JarRequest {
  161. client: &client_row_a,
  162. issuer,
  163. query: &query_a,
  164. request: None,
  165. request_uri: Some(request_uri.as_str()),
  166. };
  167. match resolve(&mut h.conn, &h.store, &replay).await {
  168. Ok(_) => {
  169. return Err(TestError::Unexpected(
  170. "replayed request_uri accepted".to_owned(),
  171. ));
  172. }
  173. Err(error) => expect_code(&error, OAuthErrorCode::InvalidRequest)?,
  174. }
  175. Ok(())
  176. }
  177. #[tokio::test]
  178. async fn par_foreign_presentation_does_not_consume_victim_record() -> Result<(), TestError> {
  179. let mut h = harness().await?;
  180. let client_a = "par-victim-client";
  181. let client_b = "par-foreign-client";
  182. let redirect_uri = "https://client.example.test/cb";
  183. insert_client(
  184. &h.pool,
  185. &h.keys,
  186. client_a,
  187. TokenEndpointAuthMethod::None,
  188. None,
  189. )
  190. .await?;
  191. insert_client(
  192. &h.pool,
  193. &h.keys,
  194. client_b,
  195. TokenEndpointAuthMethod::None,
  196. None,
  197. )
  198. .await?;
  199. attach_redirect(&h.pool, &h.keys, client_a, redirect_uri).await?;
  200. attach_redirect(&h.pool, &h.keys, client_b, redirect_uri).await?;
  201. let endpoint = h.endpoint.clone();
  202. let request_uri = push_public(&mut h, &endpoint, client_a, redirect_uri).await?;
  203. let issuer = "https://auth.example.test";
  204. let client_row_b = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_b)
  205. .await?
  206. .ok_or_else(|| TestError::Unexpected("client B missing".to_owned()))?;
  207. let query_b = HashMap::from([
  208. ("client_id".to_owned(), client_b.to_owned()),
  209. ("request_uri".to_owned(), request_uri.clone()),
  210. ]);
  211. let foreign = JarRequest {
  212. client: &client_row_b,
  213. issuer,
  214. query: &query_b,
  215. request: None,
  216. request_uri: Some(request_uri.as_str()),
  217. };
  218. match resolve(&mut h.conn, &h.store, &foreign).await {
  219. Ok(_) => {
  220. return Err(TestError::Unexpected(
  221. "cross-client request_uri accepted".to_owned(),
  222. ));
  223. }
  224. Err(error) => expect_code(&error, OAuthErrorCode::InvalidRequest)?,
  225. }
  226. let client_row_a = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_a)
  227. .await?
  228. .ok_or_else(|| TestError::Unexpected("client A missing".to_owned()))?;
  229. let query_a = HashMap::from([
  230. ("client_id".to_owned(), client_a.to_owned()),
  231. ("request_uri".to_owned(), request_uri.clone()),
  232. ]);
  233. let legitimate = JarRequest {
  234. client: &client_row_a,
  235. issuer,
  236. query: &query_a,
  237. request: None,
  238. request_uri: Some(request_uri.as_str()),
  239. };
  240. resolve(&mut h.conn, &h.store, &legitimate)
  241. .await
  242. .map_err(|error| {
  243. TestError::Unexpected(format!(
  244. "victim record burned by foreign use: {}",
  245. error.code()
  246. ))
  247. })?;
  248. Ok(())
  249. }
  250. #[tokio::test]
  251. async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
  252. let mut h = harness().await?;
  253. let client_id = "par-recovery-client";
  254. let record = par::ParRecord {
  255. client_id: client_id.to_owned(),
  256. request: "{\"response_type\":\"code\"}".to_owned(),
  257. aud: Vec::new(),
  258. };
  259. par::put(
  260. &mut h.conn,
  261. &h.store,
  262. "par-recovery-id",
  263. &record,
  264. Duration::from_secs(300),
  265. )
  266. .await
  267. .map_err(|error| TestError::Unexpected(format!("{error}")))?;
  268. // Poison the key type through the public key layout: the guarded `HGETALL`
  269. // script fails indeterminately on a string key, the recovery re-read sees
  270. // the surviving key, and the consume fails closed without deleting it.
  271. let key = h.store.par_key("par-recovery-id");
  272. let _: () = redis::cmd("DEL")
  273. .arg(&key)
  274. .query_async(&mut h.conn)
  275. .await
  276. .map_err(TestError::Redis)?;
  277. let _: () = redis::cmd("SET")
  278. .arg(&key)
  279. .arg("poison")
  280. .query_async(&mut h.conn)
  281. .await
  282. .map_err(TestError::Redis)?;
  283. match par::consume_bound(&mut h.conn, &h.store, "par-recovery-id", client_id).await {
  284. Err(_) => {}
  285. Ok(_) => {
  286. return Err(TestError::Unexpected(
  287. "indeterminate consume with a surviving key must fail closed".to_owned(),
  288. ));
  289. }
  290. }
  291. let present: i64 = redis::cmd("EXISTS")
  292. .arg(&key)
  293. .query_async(&mut h.conn)
  294. .await
  295. .map_err(TestError::Redis)?;
  296. if present != 1 {
  297. return Err(TestError::Unexpected(
  298. "failed consume must leave the surviving key intact".to_owned(),
  299. ));
  300. }
  301. // An absent key reports `Absent` through the same public API: no record,
  302. // no error, never a replay.
  303. match par::consume_bound(&mut h.conn, &h.store, "par-recovery-absent", client_id).await {
  304. Ok(par::ConsumeOutcome::Absent) => Ok(()),
  305. Ok(_) => Err(TestError::Unexpected(
  306. "absent PAR record must report Absent".to_owned(),
  307. )),
  308. Err(error) => Err(TestError::Unexpected(format!(
  309. "absent consume error: {error}"
  310. ))),
  311. }
  312. }
  313. #[tokio::test]
  314. async fn par_expired_request_uri_is_rejected() -> Result<(), TestError> {
  315. let mut h = harness().await?;
  316. let record = par::ParRecord {
  317. client_id: "par-expiry-client".to_owned(),
  318. request: "{\"response_type\":\"code\"}".to_owned(),
  319. aud: Vec::new(),
  320. };
  321. par::put(
  322. &mut h.conn,
  323. &h.store,
  324. "par-expiry-id",
  325. &record,
  326. Duration::from_millis(50),
  327. )
  328. .await
  329. .map_err(|error| TestError::Unexpected(format!("{error}")))?;
  330. tokio::time::sleep(Duration::from_millis(300)).await;
  331. let expired = par::consume(&mut h.conn, &h.store, "par-expiry-id")
  332. .await
  333. .map_err(|error| TestError::Unexpected(format!("{error}")))?;
  334. if expired.is_some() {
  335. return Err(TestError::Unexpected(
  336. "expired PAR record survived".to_owned(),
  337. ));
  338. }
  339. Ok(())
  340. }