matrix.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. //! `test(grant_device)` sender-constraint matrix at redemption time.
  2. use std::time::Duration;
  3. use axum::http::StatusCode;
  4. use super::super::{DevicePollError, DevicePollInputs, PollState, poll_device};
  5. use super::flows::{authorize, decide, now_unix, poll_once, public_inputs, secret};
  6. use super::{TestError, TestResult, harness, unique};
  7. use super::{keys, setup};
  8. use crate::{
  9. db::TokenEndpointAuthMethod,
  10. error::OAuthErrorCode,
  11. protocol::{device::DeviceHttpInputs, token::TokenFormat},
  12. };
  13. #[tokio::test]
  14. async fn test_grant_device_public_without_dpop_rejected() -> TestResult {
  15. let mut h = harness().await?;
  16. let client_id = unique("public");
  17. setup::insert_client_bound(
  18. &h.pool,
  19. &h.config,
  20. &client_id,
  21. TokenEndpointAuthMethod::None,
  22. None,
  23. "orders:read",
  24. true,
  25. )
  26. .await?;
  27. let t0 = now_unix();
  28. let key = keys::fresh_key()?;
  29. let authorize_htu = keys::device_htu(&h.config);
  30. let authorize_proof = keys::mint_proof(&key, &authorize_htu, &unique("jti"), t0)?;
  31. let authorize_inputs = DeviceHttpInputs {
  32. client_id: client_id.as_str(),
  33. scope: None,
  34. basic_header: None,
  35. body_secret: None,
  36. assertion_type: None,
  37. assertion: None,
  38. dpop_proof: Some(authorize_proof.as_str()),
  39. resource: &[],
  40. peer_certs: None,
  41. };
  42. let issued = authorize(&mut h, &authorize_inputs, t0)
  43. .await
  44. .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
  45. match poll_once(
  46. &mut h,
  47. &public_inputs(&client_id, &issued.device_code, None),
  48. t0,
  49. )
  50. .await
  51. {
  52. Err(DevicePollError::Protocol(error)) if error.code() == OAuthErrorCode::InvalidDpopProof => {
  53. Ok(())
  54. }
  55. Err(other) => Err(TestError::Unexpected(format!(
  56. "proofless public poll must be invalid_dpop_proof, got {}",
  57. other.code()
  58. ))),
  59. Ok(_) => Err(TestError::Unexpected(
  60. "a public client must never receive an unbound token".to_owned(),
  61. )),
  62. }
  63. }
  64. /// An unbound public client completes the device flow with no DPoP proof:
  65. /// authorization, approval, and poll all succeed with a `Bearer` token.
  66. #[tokio::test]
  67. async fn public_unbound_device_authorization_and_poll_succeeds() -> TestResult {
  68. let mut h = harness().await?;
  69. let client_id = unique("public-unbound");
  70. setup::insert_client(
  71. &h.pool,
  72. &h.config,
  73. &client_id,
  74. TokenEndpointAuthMethod::None,
  75. None,
  76. "orders:read",
  77. )
  78. .await?;
  79. setup::enable_scopes(&h.pool, &["orders:read"]).await?;
  80. let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
  81. let t0 = now_unix();
  82. let authorize_inputs = DeviceHttpInputs {
  83. client_id: client_id.as_str(),
  84. scope: Some("orders:read"),
  85. basic_header: None,
  86. body_secret: None,
  87. assertion_type: None,
  88. assertion: None,
  89. dpop_proof: None,
  90. resource: &[],
  91. peer_certs: None,
  92. };
  93. let issued = authorize(&mut h, &authorize_inputs, t0)
  94. .await
  95. .map_err(|error| TestError::Unexpected(format!("unbound authorize rejected: {error}")))?;
  96. if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
  97. return Err(TestError::Unexpected("decide must apply".to_owned()));
  98. }
  99. let tokens = poll_once(
  100. &mut h,
  101. &public_inputs(&client_id, &issued.device_code, None),
  102. t0 + 6,
  103. )
  104. .await
  105. .map_err(|error| TestError::Unexpected(format!("unbound poll rejected: {}", error.code())))?;
  106. if tokens.token_type != "Bearer" || tokens.scope != "orders:read" {
  107. return Err(TestError::Unexpected(
  108. "unbound device redemption must be a Bearer token".to_owned(),
  109. ));
  110. }
  111. Ok(())
  112. }
  113. /// A DPoP-bound public client cannot start the device flow without a proof:
  114. /// authorization itself is `invalid_dpop_proof` before any state is written.
  115. #[tokio::test]
  116. async fn public_bound_device_poll_requires_dpop() -> TestResult {
  117. let mut h = harness().await?;
  118. let client_id = unique("public-bound");
  119. setup::insert_client_bound(
  120. &h.pool,
  121. &h.config,
  122. &client_id,
  123. TokenEndpointAuthMethod::None,
  124. None,
  125. "orders:read",
  126. true,
  127. )
  128. .await?;
  129. let authorize_inputs = DeviceHttpInputs {
  130. client_id: client_id.as_str(),
  131. scope: None,
  132. basic_header: None,
  133. body_secret: None,
  134. assertion_type: None,
  135. assertion: None,
  136. dpop_proof: None,
  137. resource: &[],
  138. peer_certs: None,
  139. };
  140. match authorize(&mut h, &authorize_inputs, now_unix()).await {
  141. Err(error) if error.code() == OAuthErrorCode::InvalidDpopProof => Ok(()),
  142. Err(other) => Err(TestError::Unexpected(format!(
  143. "bound authorize without proof must be invalid_dpop_proof, got {other}"
  144. ))),
  145. Ok(_) => Err(TestError::Unexpected(
  146. "a bound client must never start a proof-free device flow".to_owned(),
  147. )),
  148. }
  149. }
  150. #[tokio::test]
  151. async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
  152. let mut h = harness().await?;
  153. let client_id = unique("conf");
  154. setup::insert_client(
  155. &h.pool,
  156. &h.config,
  157. &client_id,
  158. TokenEndpointAuthMethod::ClientSecretPost,
  159. Some("poll-secret"),
  160. "orders:read",
  161. )
  162. .await?;
  163. setup::enable_scopes(&h.pool, &["orders:read"]).await?;
  164. let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
  165. let t0 = now_unix();
  166. let authorize_inputs = DeviceHttpInputs {
  167. client_id: client_id.as_str(),
  168. scope: Some("orders:read"),
  169. basic_header: None,
  170. body_secret: Some(secret("poll-secret")),
  171. assertion_type: None,
  172. assertion: None,
  173. dpop_proof: None,
  174. resource: &[],
  175. peer_certs: None,
  176. };
  177. let issued = authorize(&mut h, &authorize_inputs, t0)
  178. .await
  179. .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
  180. if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
  181. return Err(TestError::Unexpected("decide must apply".to_owned()));
  182. }
  183. let poll_inputs = DevicePollInputs {
  184. client_id: client_id.as_str(),
  185. device_code: issued.device_code.as_str(),
  186. basic_header: None,
  187. body_secret: Some(secret("poll-secret")),
  188. assertion_type: None,
  189. assertion: None,
  190. dpop_proof: None,
  191. peer_certs: None,
  192. aud: &[],
  193. };
  194. let mut state = PollState {
  195. pool: &h.pool,
  196. conn: &mut h.conn,
  197. store: &h.store,
  198. keys: &h.config.keys,
  199. issuer: &h.config.issuer,
  200. device_ttl: h.config.ttl.device,
  201. interval: Duration::from_secs(crate::protocol::device::DEVICE_INTERVAL_SECS),
  202. ttl: &h.config.ttl,
  203. token_format: TokenFormat::Opaque,
  204. jwt_signer: None,
  205. cimd: None,
  206. resource_indicators_enabled: false,
  207. now_unix: t0 + 6,
  208. };
  209. let tokens = poll_device(&mut state, &poll_inputs)
  210. .await
  211. .map_err(|error| {
  212. TestError::Unexpected(format!("confidential poll rejected: {}", error.code()))
  213. })?;
  214. if tokens.token_type != "Bearer" || tokens.scope != "orders:read" {
  215. return Err(TestError::Unexpected(
  216. "unconstrained confidential redemption must be a Bearer token".to_owned(),
  217. ));
  218. }
  219. Ok(())
  220. }
  221. #[tokio::test]
  222. async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> TestResult {
  223. let mut h = harness().await?;
  224. let client_id = unique("bound-conf");
  225. setup::insert_client_bound(
  226. &h.pool,
  227. &h.config,
  228. &client_id,
  229. TokenEndpointAuthMethod::ClientSecretPost,
  230. Some("bound-secret"),
  231. "orders:read",
  232. true,
  233. )
  234. .await?;
  235. setup::enable_scopes(&h.pool, &["orders:read"]).await?;
  236. let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
  237. let t0 = now_unix();
  238. let authorize_inputs = DeviceHttpInputs {
  239. client_id: client_id.as_str(),
  240. scope: Some("orders:read"),
  241. basic_header: None,
  242. body_secret: Some(secret("bound-secret")),
  243. assertion_type: None,
  244. assertion: None,
  245. dpop_proof: None,
  246. resource: &[],
  247. peer_certs: None,
  248. };
  249. let issued = authorize(&mut h, &authorize_inputs, t0)
  250. .await
  251. .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
  252. if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
  253. return Err(TestError::Unexpected("decide must apply".to_owned()));
  254. }
  255. let poll_inputs = DevicePollInputs {
  256. client_id: client_id.as_str(),
  257. device_code: issued.device_code.as_str(),
  258. basic_header: None,
  259. body_secret: Some(secret("bound-secret")),
  260. assertion_type: None,
  261. assertion: None,
  262. dpop_proof: None,
  263. peer_certs: None,
  264. aud: &[],
  265. };
  266. let mut state = PollState {
  267. pool: &h.pool,
  268. conn: &mut h.conn,
  269. store: &h.store,
  270. keys: &h.config.keys,
  271. issuer: &h.config.issuer,
  272. device_ttl: h.config.ttl.device,
  273. interval: Duration::from_secs(crate::protocol::device::DEVICE_INTERVAL_SECS),
  274. ttl: &h.config.ttl,
  275. token_format: TokenFormat::Opaque,
  276. jwt_signer: None,
  277. cimd: None,
  278. resource_indicators_enabled: false,
  279. now_unix: t0 + 6,
  280. };
  281. match poll_device(&mut state, &poll_inputs).await {
  282. Err(DevicePollError::Protocol(error)) if error.code() == OAuthErrorCode::InvalidGrant => {}
  283. Err(other) => {
  284. return Err(TestError::Unexpected(format!(
  285. "proofless bound poll must be invalid_grant, got {}",
  286. other.code()
  287. )));
  288. }
  289. Ok(_) => {
  290. return Err(TestError::Unexpected(
  291. "a DPoP-bound client must never receive an unbound token".to_owned(),
  292. ));
  293. }
  294. }
  295. let key = keys::fresh_key()?;
  296. let token_htu = keys::token_htu(&h.config);
  297. let proof = keys::mint_proof(&key, &token_htu, &unique("jti"), t0 + 6)?;
  298. let proof_inputs = DevicePollInputs {
  299. dpop_proof: Some(proof.as_str()),
  300. ..poll_inputs
  301. };
  302. let tokens = poll_device(&mut state, &proof_inputs)
  303. .await
  304. .map_err(|error| {
  305. TestError::Unexpected(format!("bound fresh-proof poll rejected: {}", error.code()))
  306. })?;
  307. if tokens.token_type != "DPoP" || tokens.scope != "orders:read" {
  308. return Err(TestError::Unexpected(
  309. "a fresh proof must mint a DPoP token for the bound client".to_owned(),
  310. ));
  311. }
  312. Ok(())
  313. }
  314. /// RFC 6749 §5.2 and the error table in docs/OAUTH.md: a grant issued to
  315. /// another client is `invalid_grant`. The foreign poll must not consume the
  316. /// code, so the legitimate client still redeems it.
  317. #[tokio::test]
  318. async fn test_grant_device_cross_client_poll_is_invalid_grant() -> TestResult {
  319. let mut h = harness().await?;
  320. let owner_id = unique("owner");
  321. let intruder_id = unique("intruder");
  322. setup::insert_client(
  323. &h.pool,
  324. &h.config,
  325. &owner_id,
  326. TokenEndpointAuthMethod::None,
  327. None,
  328. "orders:read",
  329. )
  330. .await?;
  331. setup::insert_client(
  332. &h.pool,
  333. &h.config,
  334. &intruder_id,
  335. TokenEndpointAuthMethod::None,
  336. None,
  337. "orders:read",
  338. )
  339. .await?;
  340. setup::enable_scopes(&h.pool, &["orders:read"]).await?;
  341. let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
  342. let t0 = now_unix();
  343. let owner_key = keys::fresh_key()?;
  344. let authorize_htu = keys::device_htu(&h.config);
  345. let poll_htu = keys::token_htu(&h.config);
  346. let authorize_proof = keys::mint_proof(&owner_key, &authorize_htu, &unique("jti"), t0)?;
  347. let authorize_inputs = DeviceHttpInputs {
  348. client_id: owner_id.as_str(),
  349. scope: Some("orders:read"),
  350. basic_header: None,
  351. body_secret: None,
  352. assertion_type: None,
  353. assertion: None,
  354. dpop_proof: Some(authorize_proof.as_str()),
  355. resource: &[],
  356. peer_certs: None,
  357. };
  358. let issued = authorize(&mut h, &authorize_inputs, t0)
  359. .await
  360. .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
  361. if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
  362. return Err(TestError::Unexpected("decide must apply".to_owned()));
  363. }
  364. let t1 = t0 + 6;
  365. let intruder_key = keys::fresh_key()?;
  366. let intruder_proof = keys::mint_proof(&intruder_key, &poll_htu, &unique("jti"), t1)?;
  367. match poll_once(
  368. &mut h,
  369. &public_inputs(&intruder_id, &issued.device_code, Some(&intruder_proof)),
  370. t1,
  371. )
  372. .await
  373. {
  374. Err(DevicePollError::Protocol(error))
  375. if error.code() == OAuthErrorCode::InvalidGrant
  376. && error.status() == StatusCode::BAD_REQUEST => {}
  377. Err(other) => {
  378. return Err(TestError::Unexpected(format!(
  379. "cross-client poll must be invalid_grant 400, got {}",
  380. other.code()
  381. )));
  382. }
  383. Ok(_) => {
  384. return Err(TestError::Unexpected(
  385. "a foreign client must never mint a token".to_owned(),
  386. ));
  387. }
  388. }
  389. // The rejected poll left the grant untouched: the owner still redeems it.
  390. let owner_proof = keys::mint_proof(&owner_key, &poll_htu, &unique("jti"), t1)?;
  391. let tokens = poll_once(
  392. &mut h,
  393. &public_inputs(&owner_id, &issued.device_code, Some(&owner_proof)),
  394. t1,
  395. )
  396. .await
  397. .map_err(|error| TestError::Unexpected(format!("owner redemption rejected: {}", error.code())))?;
  398. if tokens.token_type != "DPoP"
  399. || tokens.access_token.is_empty()
  400. || tokens.refresh_token.is_empty()
  401. || tokens.scope != "orders:read"
  402. {
  403. return Err(TestError::Unexpected(
  404. "the owner must still redeem the untouched grant".to_owned(),
  405. ));
  406. }
  407. Ok(())
  408. }