dcr.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. //! DCR tests (todo 30) against a `wiremock` fake AS — never a live server.
  2. //!
  3. //! All tokens and secrets below are synthetic test values.
  4. use std::{fs, path::PathBuf};
  5. use provision::cli_driver::DEMO_POST_LOGOUT_URI;
  6. use provision::dcr::{
  7. ClientRecord, POST_LOGOUT_NOT_ATTACHED_NOTE, PostLogoutAttach, PostLogoutOutcome,
  8. ReconcileOutcome, RegisterInputs, Registration, load, persist, reconcile, register,
  9. };
  10. use wiremock::{
  11. Mock, MockServer, ResponseTemplate,
  12. matchers::{body_json, header, method, path},
  13. };
  14. const FIRST_IAT: &str = "iat-first-use-only";
  15. const FRESH_IAT: &str = "iat-fresh-second-use-only";
  16. fn inputs(issuer: &str) -> RegisterInputs {
  17. RegisterInputs {
  18. issuer: issuer.to_owned(),
  19. ..RegisterInputs::default()
  20. }
  21. }
  22. fn expected_body() -> serde_json::Value {
  23. serde_json::json!({
  24. "client_name": "oauth2-wasm-demo",
  25. "token_endpoint_auth_method": "client_secret_basic",
  26. "grant_types": ["authorization_code", "refresh_token"],
  27. "response_types": ["code"],
  28. "redirect_uris": ["http://127.0.0.1:3000/callback"],
  29. "scope": "demo.read",
  30. "dpop_bound_access_tokens": true,
  31. })
  32. }
  33. fn registration_response(server: &MockServer) -> serde_json::Value {
  34. serde_json::json!({
  35. "client_id": "client-1",
  36. "client_secret": "secret-1",
  37. "registration_access_token": "rat-1",
  38. "registration_client_uri": format!("{}/register/client-1", server.uri()),
  39. "redirect_uris": ["http://127.0.0.1:3000/callback"],
  40. "client_name": "oauth2-wasm-demo",
  41. "token_endpoint_auth_method": "client_secret_basic",
  42. "grant_types": ["authorization_code", "refresh_token"],
  43. "response_types": ["code"],
  44. "scope": "demo.read",
  45. "dpop_bound_access_tokens": true,
  46. "client_id_issued_at": 1_700_000_000,
  47. })
  48. }
  49. fn test_client() -> reqwest::Client {
  50. oauth_core::discovery::build_client().expect("test http client")
  51. }
  52. /// Install `fake-d-oauth.sh` executable in a fresh tempdir; return the tempdir,
  53. /// CLI path, and state dir.
  54. fn install_fake_cli() -> (tempfile::TempDir, PathBuf, PathBuf) {
  55. let dir = tempfile::tempdir().expect("tempdir");
  56. let cli = dir.path().join("fake-d-oauth.sh");
  57. let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fake-d-oauth.sh");
  58. let script = fs::read_to_string(&source).expect("read fixture");
  59. fs::write(&cli, script).expect("write fake cli");
  60. #[cfg(unix)]
  61. {
  62. use std::os::unix::fs::PermissionsExt as _;
  63. fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod fake cli");
  64. }
  65. let state = dir.path().join("state");
  66. fs::create_dir(&state).expect("state dir");
  67. (dir, cli, state)
  68. }
  69. /// Write a `.env` file pointing the fake CLI at `state`.
  70. fn write_attach_env(dir: &tempfile::TempDir, state: &std::path::Path) -> PathBuf {
  71. let path = dir.path().join("attach.env");
  72. fs::write(&path, format!("FAKE_STATE_DIR={}\n", state.display())).expect("write env file");
  73. path
  74. }
  75. /// Attach config wired to the fake CLI.
  76. fn attach_config(cli: PathBuf, env_file: PathBuf) -> PostLogoutAttach {
  77. PostLogoutAttach {
  78. cli: Some(cli),
  79. env_file: Some(env_file),
  80. uri: DEMO_POST_LOGOUT_URI.to_owned(),
  81. }
  82. }
  83. /// The argv line the fake CLI records for the attach step.
  84. fn attach_line(client_id: &str) -> String {
  85. format!(
  86. "client attach-redirect-uri --client-id {client_id} --uri {DEMO_POST_LOGOUT_URI} --kind post_logout"
  87. )
  88. }
  89. #[cfg(unix)]
  90. fn assert_0600(path: &std::path::Path) {
  91. use std::os::unix::fs::PermissionsExt as _;
  92. let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777;
  93. assert_eq!(mode, 0o600, "client record must be owner-only");
  94. }
  95. #[tokio::test]
  96. async fn dcr_registers_and_persists_0600() {
  97. let server = MockServer::start().await;
  98. Mock::given(method("POST"))
  99. .and(path("/register"))
  100. .and(header(
  101. "authorization",
  102. format!("Bearer {FIRST_IAT}").as_str(),
  103. ))
  104. .and(body_json(expected_body()))
  105. .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
  106. .mount(&server)
  107. .await;
  108. let dir = tempfile::tempdir().expect("tempdir");
  109. let out = dir.path().join("client.json");
  110. let record = register(&test_client(), &inputs(&server.uri()), FIRST_IAT)
  111. .await
  112. .expect("register");
  113. assert_eq!(record.client_id, "client-1");
  114. assert_eq!(
  115. record.registration_client_uri,
  116. format!("{}/register/client-1", server.uri())
  117. );
  118. persist(&record, &out).expect("persist");
  119. #[cfg(unix)]
  120. assert_0600(&out);
  121. let stored = load(&out).expect("reload");
  122. assert_eq!(stored, record);
  123. // The persisted file carries the minted credentials only — never the IAT.
  124. let raw = fs::read_to_string(&out).expect("read record");
  125. assert!(!raw.contains(FIRST_IAT));
  126. // The redacted Debug never leaks the secret or the management token.
  127. let debug = format!("{record:?}");
  128. assert!(!debug.contains("secret-1"));
  129. assert!(!debug.contains("rat-1"));
  130. }
  131. #[tokio::test]
  132. async fn dcr_rerun_with_stale_record_reregisters_with_fresh_iat() {
  133. let server = MockServer::start().await;
  134. let stale_uri = format!("{}/register/stale-client", server.uri());
  135. Mock::given(method("GET"))
  136. .and(path("/register/stale-client"))
  137. .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
  138. "error": "invalid_client",
  139. })))
  140. .mount(&server)
  141. .await;
  142. Mock::given(method("POST"))
  143. .and(path("/register"))
  144. .and(header(
  145. "authorization",
  146. format!("Bearer {FRESH_IAT}").as_str(),
  147. ))
  148. .and(body_json(expected_body()))
  149. .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
  150. .mount(&server)
  151. .await;
  152. let dir = tempfile::tempdir().expect("tempdir");
  153. let out = dir.path().join("client.json");
  154. persist(
  155. &ClientRecord {
  156. client_id: "stale-client".to_owned(),
  157. client_secret: "stale-secret".to_owned(),
  158. registration_access_token: "stale-rat".to_owned(),
  159. registration_client_uri: stale_uri,
  160. },
  161. &out,
  162. )
  163. .expect("persist stale record");
  164. let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |status| {
  165. assert_eq!(status, 401);
  166. Ok(FRESH_IAT.to_owned())
  167. })
  168. .await
  169. .expect("reconcile");
  170. assert_eq!(outcome, ReconcileOutcome::ReRegistered);
  171. let stored = load(&out).expect("reload");
  172. assert_eq!(stored.client_id, "client-1");
  173. assert!(
  174. !fs::read_to_string(&out)
  175. .expect("read")
  176. .contains("stale-secret")
  177. );
  178. #[cfg(unix)]
  179. assert_0600(&out);
  180. }
  181. #[tokio::test]
  182. async fn dcr_live_record_is_kept_without_reregistering() {
  183. let server = MockServer::start().await;
  184. Mock::given(method("GET"))
  185. .and(path("/register/client-1"))
  186. .and(header("authorization", "Bearer rat-1"))
  187. .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
  188. "client_id": "client-1",
  189. })))
  190. .mount(&server)
  191. .await;
  192. let dir = tempfile::tempdir().expect("tempdir");
  193. let out = dir.path().join("client.json");
  194. let before = ClientRecord {
  195. client_id: "client-1".to_owned(),
  196. client_secret: "secret-1".to_owned(),
  197. registration_access_token: "rat-1".to_owned(),
  198. registration_client_uri: format!("{}/register/client-1", server.uri()),
  199. };
  200. persist(&before, &out).expect("persist");
  201. let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |_| {
  202. panic!("a live record must not mint a fresh IAT");
  203. })
  204. .await
  205. .expect("reconcile");
  206. assert_eq!(outcome, ReconcileOutcome::Kept);
  207. assert_eq!(load(&out).expect("reload"), before);
  208. }
  209. #[tokio::test]
  210. async fn dcr_invalid_redirect_uri_prints_remediation() {
  211. let server = MockServer::start().await;
  212. Mock::given(method("POST"))
  213. .and(path("/register"))
  214. .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
  215. "error": "invalid_redirect_uri",
  216. "error_description": "redirect URI is not in the catalog",
  217. })))
  218. .mount(&server)
  219. .await;
  220. match register(&test_client(), &inputs(&server.uri()), FIRST_IAT).await {
  221. Err(provision::dcr::DcrError::Rejected {
  222. message,
  223. code,
  224. status,
  225. }) => {
  226. assert_eq!(status, 400);
  227. assert_eq!(code.as_deref(), Some("invalid_redirect_uri"));
  228. assert!(
  229. message.contains("dcr_redirect_policy=catalog"),
  230. "remediation: {message}"
  231. );
  232. assert!(
  233. message.contains("catalog_loopback"),
  234. "remediation: {message}"
  235. );
  236. }
  237. other => panic!("expected an invalid_redirect_uri rejection, got {other:?}"),
  238. }
  239. }
  240. #[tokio::test]
  241. async fn dcr_stale_record_without_iat_source_fails_typed() {
  242. let server = MockServer::start().await;
  243. Mock::given(method("GET"))
  244. .and(path("/register/stale-client"))
  245. .respond_with(ResponseTemplate::new(404))
  246. .mount(&server)
  247. .await;
  248. let dir = tempfile::tempdir().expect("tempdir");
  249. let out = dir.path().join("client.json");
  250. persist(
  251. &ClientRecord {
  252. client_id: "stale-client".to_owned(),
  253. client_secret: "stale-secret".to_owned(),
  254. registration_access_token: "stale-rat".to_owned(),
  255. registration_client_uri: format!("{}/register/stale-client", server.uri()),
  256. },
  257. &out,
  258. )
  259. .expect("persist stale record");
  260. match reconcile(&test_client(), &inputs(&server.uri()), &out, |status| {
  261. Err(provision::dcr::DcrError::FreshIatRequired { status })
  262. })
  263. .await
  264. {
  265. Err(provision::dcr::DcrError::FreshIatRequired { status }) => assert_eq!(status, 404),
  266. other => panic!("expected FreshIatRequired, got {other:?}"),
  267. }
  268. }
  269. #[tokio::test]
  270. async fn dcr_register_then_attaches_post_logout() {
  271. let server = MockServer::start().await;
  272. Mock::given(method("POST"))
  273. .and(path("/register"))
  274. .and(header(
  275. "authorization",
  276. format!("Bearer {FIRST_IAT}").as_str(),
  277. ))
  278. .and(body_json(expected_body()))
  279. .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
  280. .mount(&server)
  281. .await;
  282. let (dir, cli, state) = install_fake_cli();
  283. let env_file = write_attach_env(&dir, &state);
  284. let out = dir.path().join("client.json");
  285. let reg_inputs = inputs(&server.uri());
  286. let attach = attach_config(cli, env_file);
  287. let registration = Registration {
  288. inputs: &reg_inputs,
  289. out: &out,
  290. attach: &attach,
  291. };
  292. let (record, outcome) = registration
  293. .register_and_attach(&test_client(), FIRST_IAT)
  294. .await
  295. .expect("register then attach");
  296. assert_eq!(record.client_id, "client-1");
  297. assert_eq!(outcome, PostLogoutOutcome::Attached);
  298. assert!(out.exists(), "the record must be persisted before attach");
  299. let log = fs::read_to_string(state.join("call.log")).expect("call log");
  300. assert_eq!(log.lines().last(), Some(attach_line("client-1").as_str()));
  301. let _ = dir;
  302. }
  303. #[tokio::test]
  304. async fn dcr_reconcile_reregister_then_attaches_post_logout() {
  305. let server = MockServer::start().await;
  306. let stale_uri = format!("{}/register/stale-client", server.uri());
  307. Mock::given(method("GET"))
  308. .and(path("/register/stale-client"))
  309. .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
  310. "error": "invalid_client",
  311. })))
  312. .mount(&server)
  313. .await;
  314. Mock::given(method("POST"))
  315. .and(path("/register"))
  316. .and(header(
  317. "authorization",
  318. format!("Bearer {FRESH_IAT}").as_str(),
  319. ))
  320. .and(body_json(expected_body()))
  321. .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
  322. .mount(&server)
  323. .await;
  324. let (dir, cli, state) = install_fake_cli();
  325. let env_file = write_attach_env(&dir, &state);
  326. let out = dir.path().join("client.json");
  327. persist(
  328. &ClientRecord {
  329. client_id: "stale-client".to_owned(),
  330. client_secret: "stale-secret".to_owned(),
  331. registration_access_token: "stale-rat".to_owned(),
  332. registration_client_uri: stale_uri,
  333. },
  334. &out,
  335. )
  336. .expect("persist stale record");
  337. let reg_inputs = inputs(&server.uri());
  338. let attach = attach_config(cli, env_file);
  339. let registration = Registration {
  340. inputs: &reg_inputs,
  341. out: &out,
  342. attach: &attach,
  343. };
  344. let (outcome, attached) = registration
  345. .reconcile_and_attach(&test_client(), |status| {
  346. assert_eq!(status, 401);
  347. Ok(FRESH_IAT.to_owned())
  348. })
  349. .await
  350. .expect("reconcile then attach");
  351. assert_eq!(outcome, ReconcileOutcome::ReRegistered);
  352. assert_eq!(attached, Some(PostLogoutOutcome::Attached));
  353. assert_eq!(load(&out).expect("reload").client_id, "client-1");
  354. let log = fs::read_to_string(state.join("call.log")).expect("call log");
  355. assert!(
  356. log.lines().any(|line| line == attach_line("client-1")),
  357. "attach call missing from log: {log}"
  358. );
  359. let _ = dir;
  360. }
  361. #[tokio::test]
  362. async fn dcr_live_record_is_kept_without_attaching() {
  363. let server = MockServer::start().await;
  364. Mock::given(method("GET"))
  365. .and(path("/register/client-1"))
  366. .and(header("authorization", "Bearer rat-1"))
  367. .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
  368. "client_id": "client-1",
  369. })))
  370. .mount(&server)
  371. .await;
  372. let (dir, cli, state) = install_fake_cli();
  373. let env_file = write_attach_env(&dir, &state);
  374. let out = dir.path().join("client.json");
  375. persist(
  376. &ClientRecord {
  377. client_id: "client-1".to_owned(),
  378. client_secret: "secret-1".to_owned(),
  379. registration_access_token: "rat-1".to_owned(),
  380. registration_client_uri: format!("{}/register/client-1", server.uri()),
  381. },
  382. &out,
  383. )
  384. .expect("persist live record");
  385. let reg_inputs = inputs(&server.uri());
  386. let attach = attach_config(cli, env_file);
  387. let registration = Registration {
  388. inputs: &reg_inputs,
  389. out: &out,
  390. attach: &attach,
  391. };
  392. let (outcome, attached) = registration
  393. .reconcile_and_attach(&test_client(), |_| {
  394. panic!("a live record must not mint a fresh IAT")
  395. })
  396. .await
  397. .expect("reconcile");
  398. assert_eq!(outcome, ReconcileOutcome::Kept);
  399. assert_eq!(attached, None);
  400. assert!(
  401. !state.join("call.log").exists(),
  402. "a live record must not trigger the attach step"
  403. );
  404. let _ = dir;
  405. }
  406. #[test]
  407. fn dcr_attach_skipped_without_cli_is_typed() {
  408. let skip = PostLogoutAttach {
  409. cli: None,
  410. env_file: None,
  411. uri: DEMO_POST_LOGOUT_URI.to_owned(),
  412. };
  413. assert_eq!(
  414. skip.apply("client-1").expect("skip is not an error"),
  415. PostLogoutOutcome::SkippedNoCli
  416. );
  417. // Only one of the two CLI paths is not enough to attach.
  418. let partial = PostLogoutAttach {
  419. cli: Some(PathBuf::from("/bin/false")),
  420. env_file: None,
  421. uri: DEMO_POST_LOGOUT_URI.to_owned(),
  422. };
  423. assert_eq!(
  424. partial.apply("client-1").expect("skip is not an error"),
  425. PostLogoutOutcome::SkippedNoCli
  426. );
  427. assert!(!POST_LOGOUT_NOT_ATTACHED_NOTE.is_empty());
  428. }