tests_delete.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. //! `test(dcr_manage)` delete suite: cross-client denial, DELETE, revocation,
  2. //! and Redis-loss survival.
  3. use testcontainers::{
  4. GenericImage, ImageExt as _,
  5. core::{IntoContainerPort, WaitFor},
  6. runners::AsyncRunner,
  7. };
  8. use uuid::Uuid;
  9. use super::delete::delete_client;
  10. use super::read_client;
  11. use super::support::{
  12. Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
  13. harness_with_redis, manage_parts, mtls_request, register, register_mtls, try_register,
  14. };
  15. use crate::db::client_repo::{self, NewClient};
  16. use crate::error::OAuthErrorCode;
  17. use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService};
  18. #[tokio::test]
  19. async fn dcr_manage_cross_client_forbidden() -> TestResult {
  20. let mut harness = harness("catalog").await?;
  21. let first = register(&mut harness, "https://app.example.test/a", None).await?;
  22. let second = register(&mut harness, "https://app.example.test/b", None).await?;
  23. let request = crate::protocol::dcr::RegisterRequest {
  24. redirect_uris: Some(vec!["https://app.example.test/b".to_owned()]),
  25. client_name: Some("attacker rename".to_owned()),
  26. token_endpoint_auth_method: Some("client_secret_basic".to_owned()),
  27. grant_types: Some(vec!["authorization_code".to_owned()]),
  28. response_types: Some(vec!["code".to_owned()]),
  29. scope: None,
  30. jwks: None,
  31. jwks_uri: None,
  32. tls_client_auth_subject_dn: None,
  33. dpop_bound_access_tokens: Some(true),
  34. contacts: None,
  35. request_uris: None,
  36. software_id: None,
  37. };
  38. let (mut stores, ctx) = manage_parts(&mut harness);
  39. match read_client(&mut stores, &ctx, &second.client_id, Some(&first.reg_token)).await {
  40. Ok(_) => {
  41. return Err(TestError::Unexpected(
  42. "cross-client read succeeded".to_owned(),
  43. ));
  44. }
  45. Err(error) => expect_forbidden(error)?,
  46. }
  47. let (mut stores, ctx) = manage_parts(&mut harness);
  48. match super::update::update_client(
  49. &mut stores,
  50. &ctx,
  51. &second.client_id,
  52. Some(&first.reg_token),
  53. &request,
  54. )
  55. .await
  56. {
  57. Ok(_) => {
  58. return Err(TestError::Unexpected(
  59. "cross-client update succeeded".to_owned(),
  60. ));
  61. }
  62. Err(error) => expect_forbidden(error)?,
  63. }
  64. let (mut stores, ctx) = manage_parts(&mut harness);
  65. match delete_client(&mut stores, &ctx, &second.client_id, Some(&first.reg_token)).await {
  66. Ok(()) => {
  67. return Err(TestError::Unexpected(
  68. "cross-client delete succeeded".to_owned(),
  69. ));
  70. }
  71. Err(error) => expect_forbidden(error)?,
  72. }
  73. let row =
  74. client_repo::find_active_by_client_id(&harness.pool, &harness.config.keys, &second.client_id)
  75. .await?;
  76. if row.is_none() {
  77. return Err(TestError::Unexpected(
  78. "victim client changed by cross-client calls".to_owned(),
  79. ));
  80. }
  81. Ok(())
  82. }
  83. #[tokio::test]
  84. async fn dcr_manage_delete_deactivates() -> TestResult {
  85. let mut harness = harness("catalog").await?;
  86. let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
  87. {
  88. let (mut stores, ctx) = manage_parts(&mut harness);
  89. delete_client(
  90. &mut stores,
  91. &ctx,
  92. &registered.client_id,
  93. Some(&registered.reg_token),
  94. )
  95. .await
  96. .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
  97. }
  98. if client_repo::find_active_by_client_id(
  99. &harness.pool,
  100. &harness.config.keys,
  101. &registered.client_id,
  102. )
  103. .await?
  104. .is_some()
  105. {
  106. return Err(TestError::Unexpected(
  107. "deleted client still active".to_owned(),
  108. ));
  109. }
  110. let listed = client_repo::list(&harness.pool, &harness.config.keys, true).await?;
  111. let kept = listed
  112. .iter()
  113. .find(|row| row.client_id == registered.client_id)
  114. .ok_or_else(|| TestError::Unexpected("deleted row missing from list".to_owned()))?;
  115. if kept.active {
  116. return Err(TestError::Unexpected(
  117. "deleted row still flagged active".to_owned(),
  118. ));
  119. }
  120. let (mut stores, ctx) = manage_parts(&mut harness);
  121. match read_client(
  122. &mut stores,
  123. &ctx,
  124. &registered.client_id,
  125. Some(&registered.reg_token),
  126. )
  127. .await
  128. {
  129. Ok(_) => Err(TestError::Unexpected(
  130. "read after delete succeeded".to_owned(),
  131. )),
  132. Err(error) => expect_unauthorized(error),
  133. }
  134. }
  135. #[tokio::test]
  136. async fn dcr_manage_delete_revokes_tokens() -> TestResult {
  137. let mut harness = harness("catalog").await?;
  138. let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
  139. let other = register(&mut harness, "https://app.example.test/other", None).await?;
  140. let service = TokenService::new(&harness.config.ttl, &harness.config.keys)
  141. .map_err(|error| TestError::Unexpected(format!("token service: {error}")))?;
  142. let now = time::OffsetDateTime::now_utc().unix_timestamp();
  143. let mut victim_hashes: Vec<String> = Vec::new();
  144. for _ in 0..2 {
  145. let minted = service
  146. .mint(
  147. &mut harness.conn,
  148. &harness.store,
  149. &MintRequest {
  150. client_id: registered.client_id.clone(),
  151. client_kind: ClientKind::Confidential,
  152. dpop_required: false,
  153. subject: String::new(),
  154. scope: String::new(),
  155. grant_id: Uuid::new_v4().to_string(),
  156. proof: SenderProof::none(),
  157. aud: Vec::new(),
  158. refresh_aud: Vec::new(),
  159. format: TokenFormat::Opaque,
  160. },
  161. now,
  162. )
  163. .await
  164. .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
  165. victim_hashes.push(minted.access_hash);
  166. victim_hashes.push(minted.refresh_hash);
  167. }
  168. let survivor = service
  169. .mint(
  170. &mut harness.conn,
  171. &harness.store,
  172. &MintRequest {
  173. client_id: other.client_id.clone(),
  174. client_kind: ClientKind::Confidential,
  175. dpop_required: false,
  176. subject: String::new(),
  177. scope: String::new(),
  178. grant_id: Uuid::new_v4().to_string(),
  179. proof: SenderProof::none(),
  180. aud: Vec::new(),
  181. refresh_aud: Vec::new(),
  182. format: TokenFormat::Opaque,
  183. },
  184. now,
  185. )
  186. .await
  187. .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
  188. {
  189. let (mut stores, ctx) = manage_parts(&mut harness);
  190. delete_client(
  191. &mut stores,
  192. &ctx,
  193. &registered.client_id,
  194. Some(&registered.reg_token),
  195. )
  196. .await
  197. .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
  198. }
  199. for hash in &victim_hashes {
  200. let access = crate::store::tokens::get_access(&mut harness.conn, &harness.store, hash)
  201. .await
  202. .map_err(|error| TestError::Unexpected(format!("lookup failed: {error}")))?;
  203. let refresh = crate::store::tokens::get_refresh(&mut harness.conn, &harness.store, hash)
  204. .await
  205. .map_err(|error| TestError::Unexpected(format!("lookup failed: {error}")))?;
  206. if access.is_some() || refresh.is_some() {
  207. return Err(TestError::Unexpected(
  208. "deleted client's token survived".to_owned(),
  209. ));
  210. }
  211. }
  212. let live =
  213. crate::store::tokens::get_access(&mut harness.conn, &harness.store, &survivor.access_hash)
  214. .await
  215. .map_err(|error| TestError::Unexpected(format!("lookup failed: {error}")))?;
  216. if live.is_none() {
  217. return Err(TestError::Unexpected(
  218. "unrelated client's token was revoked".to_owned(),
  219. ));
  220. }
  221. Ok(())
  222. }
  223. #[tokio::test]
  224. async fn dcr_manage_delete_token_denials_match_read() -> TestResult {
  225. // RFC 7592 §2.3: DELETE is authenticated by the registration access token.
  226. // The same denial contract as GET applies: absent or unknown/deactivated
  227. // client answers 401, a mismatched credential on an existing client answers
  228. // 403. A successful DELETE invalidates the token, so replaying it answers
  229. // 401 (the deactivated row no longer authorizes management).
  230. let mut harness = harness("catalog").await?;
  231. let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
  232. {
  233. let (mut stores, ctx) = manage_parts(&mut harness);
  234. match delete_client(&mut stores, &ctx, &registered.client_id, None).await {
  235. Ok(()) => {
  236. return Err(TestError::Unexpected(
  237. "tokenless DELETE succeeded".to_owned(),
  238. ));
  239. }
  240. Err(error) => expect_unauthorized(error)?,
  241. }
  242. }
  243. {
  244. let (mut stores, ctx) = manage_parts(&mut harness);
  245. match delete_client(
  246. &mut stores,
  247. &ctx,
  248. "no-such-client",
  249. Some(&registered.reg_token),
  250. )
  251. .await
  252. {
  253. Ok(()) => {
  254. return Err(TestError::Unexpected(
  255. "unknown-client DELETE succeeded".to_owned(),
  256. ));
  257. }
  258. Err(error) => expect_unauthorized(error)?,
  259. }
  260. }
  261. {
  262. let (mut stores, ctx) = manage_parts(&mut harness);
  263. match delete_client(
  264. &mut stores,
  265. &ctx,
  266. &registered.client_id,
  267. Some("garbage-token-value"),
  268. )
  269. .await
  270. {
  271. Ok(()) => {
  272. return Err(TestError::Unexpected(
  273. "wrong-token DELETE succeeded".to_owned(),
  274. ));
  275. }
  276. Err(error) => expect_forbidden(error)?,
  277. }
  278. }
  279. {
  280. let (mut stores, ctx) = manage_parts(&mut harness);
  281. delete_client(
  282. &mut stores,
  283. &ctx,
  284. &registered.client_id,
  285. Some(&registered.reg_token),
  286. )
  287. .await
  288. .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
  289. }
  290. {
  291. let (mut stores, ctx) = manage_parts(&mut harness);
  292. match delete_client(
  293. &mut stores,
  294. &ctx,
  295. &registered.client_id,
  296. Some(&registered.reg_token),
  297. )
  298. .await
  299. {
  300. Ok(()) => {
  301. return Err(TestError::Unexpected(
  302. "revoked token accepted on repeated DELETE".to_owned(),
  303. ));
  304. }
  305. Err(error) => expect_unauthorized(error)?,
  306. }
  307. }
  308. Ok(())
  309. }
  310. /// Load a client row regardless of its `active` flag.
  311. async fn find_any_row(
  312. harness: &Harness,
  313. client_id: &str,
  314. ) -> Result<client_repo::Client, TestError> {
  315. client_repo::list(&harness.pool, &harness.config.keys, true)
  316. .await?
  317. .into_iter()
  318. .find(|row| row.client_id == client_id)
  319. .ok_or_else(|| TestError::Unexpected(format!("client row {client_id} missing")))
  320. }
  321. /// Insert a `self_signed_tls_client_auth` client directly.
  322. ///
  323. /// DCR's `RegisterRequest` has no certificate-thumbprint field, so the
  324. /// repository is the only seam that can express this mTLS identity; the row
  325. /// then goes through the real management DELETE path with the supplied
  326. /// registration token.
  327. async fn insert_thumbprint_client(
  328. harness: &Harness,
  329. thumbprint: &str,
  330. registration_token_hash: &str,
  331. ) -> Result<client_repo::Client, TestError> {
  332. let client_id = crate::crypto::tokens::mint_token()
  333. .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
  334. let new = NewClient {
  335. id: Uuid::new_v4(),
  336. client_id,
  337. client_secret_hash: None,
  338. client_secret_expires_at: None,
  339. client_name: "dcr manage thumbprint client".to_owned(),
  340. grant_types: vec!["authorization_code".to_owned()],
  341. response_types: vec!["code".to_owned()],
  342. scope: String::new(),
  343. token_endpoint_auth_method: "self_signed_tls_client_auth".to_owned(),
  344. jwks: None,
  345. jwks_uri: None,
  346. tls_client_auth_subject_dn: None,
  347. tls_client_auth_cert_thumbprint: Some(thumbprint.to_owned()),
  348. request_uris: None,
  349. contacts: None,
  350. software_id: None,
  351. dpop_bound_access_tokens: false,
  352. access_token_format: "opaque".to_owned(),
  353. registration_access_token_hash: Some(registration_token_hash.to_owned()),
  354. };
  355. client_repo::insert(
  356. &harness.pool,
  357. &harness.config.keys,
  358. harness.config.pii_dual_write,
  359. &new,
  360. )
  361. .await
  362. .map_err(TestError::from)
  363. }
  364. #[tokio::test]
  365. async fn dcr_manage_delete_subject_dn_stays_reserved() -> TestResult {
  366. // RFC 7592 §2.3 DELETE deactivates the client; it does not erase the row.
  367. // mTLS identities stay unique per the partial indexes over every non-NULL
  368. // identity, independent of `active` (docs/SECURITY.md "Sender constraints";
  369. // docs/DATABASE.md "oauth_clients" and its edge cases). Re-registering the
  370. // same subject DN must therefore stay a typed 400 invalid_client_metadata
  371. // duplicate, while a different DN is unaffected.
  372. let mut harness = harness("catalog").await?;
  373. let redirect = "https://app.example.test/cb";
  374. let subject_dn = "CN=reserved-dn, O=Example";
  375. let first = register_mtls(&mut harness, redirect, subject_dn).await?;
  376. {
  377. let (mut stores, ctx) = manage_parts(&mut harness);
  378. delete_client(&mut stores, &ctx, &first.client_id, Some(&first.reg_token))
  379. .await
  380. .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
  381. }
  382. let inactive = find_any_row(&harness, &first.client_id).await?;
  383. if inactive.active {
  384. return Err(TestError::Unexpected(
  385. "deleted client row still flagged active".to_owned(),
  386. ));
  387. }
  388. if inactive.tls_client_auth_subject_dn.as_deref() != Some(subject_dn) {
  389. return Err(TestError::Unexpected(format!(
  390. "deactivated row lost its subject DN: {:?}",
  391. inactive.tls_client_auth_subject_dn
  392. )));
  393. }
  394. match try_register(&mut harness, &mtls_request(redirect, subject_dn)).await? {
  395. Ok(_) => {
  396. return Err(TestError::Unexpected(
  397. "deactivated client's subject DN was freed".to_owned(),
  398. ));
  399. }
  400. Err(error) => expect_app_error(error, OAuthErrorCode::InvalidClientMetadata, 400)?,
  401. }
  402. match try_register(
  403. &mut harness,
  404. &mtls_request(redirect, "CN=fresh-dn, O=Example"),
  405. )
  406. .await?
  407. {
  408. Ok(response) => {
  409. if response.client_id == first.client_id {
  410. return Err(TestError::Unexpected(
  411. "fresh registration reused the deleted client_id".to_owned(),
  412. ));
  413. }
  414. }
  415. Err(error) => {
  416. return Err(TestError::Unexpected(format!(
  417. "unaffected subject DN rejected: {error}"
  418. )));
  419. }
  420. }
  421. Ok(())
  422. }
  423. #[tokio::test]
  424. async fn dcr_manage_delete_thumbprint_stays_reserved() -> TestResult {
  425. // Same identity contract for `self_signed_tls_client_auth`: the certificate
  426. // thumbprint is a partial unique index over every non-NULL value with no
  427. // `active` predicate (docs/DATABASE.md "oauth_clients"), so deactivation
  428. // must not free it. The row is driven through the real management DELETE.
  429. let mut harness = harness("catalog").await?;
  430. let thumbprint = "x5t-s256-manage-thumbprint-fixture";
  431. let raw = crate::crypto::tokens::mint_token()
  432. .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
  433. let hash = super::super::keyed_hash(&harness.config, &raw)
  434. .map_err(|error| TestError::Unexpected(format!("hash failed: {error}")))?;
  435. let first = insert_thumbprint_client(&harness, thumbprint, &hash).await?;
  436. {
  437. let (mut stores, ctx) = manage_parts(&mut harness);
  438. delete_client(&mut stores, &ctx, &first.client_id, Some(&raw))
  439. .await
  440. .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
  441. }
  442. let inactive = find_any_row(&harness, &first.client_id).await?;
  443. if inactive.active {
  444. return Err(TestError::Unexpected(
  445. "deleted client row still flagged active".to_owned(),
  446. ));
  447. }
  448. if inactive.tls_client_auth_cert_thumbprint.as_deref() != Some(thumbprint) {
  449. return Err(TestError::Unexpected(
  450. "deactivated row lost its certificate thumbprint".to_owned(),
  451. ));
  452. }
  453. let second_raw = crate::crypto::tokens::mint_token()
  454. .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
  455. let second_hash = super::super::keyed_hash(&harness.config, &second_raw)
  456. .map_err(|error| TestError::Unexpected(format!("hash failed: {error}")))?;
  457. match insert_thumbprint_client(&harness, thumbprint, &second_hash).await {
  458. Ok(_) => Err(TestError::Unexpected(
  459. "deactivated client's certificate thumbprint was freed".to_owned(),
  460. )),
  461. Err(TestError::App(crate::error::AppError::DuplicateClientIdentity)) => Ok(()),
  462. Err(error) => Err(TestError::Unexpected(format!(
  463. "unexpected duplicate-identity failure: {error}"
  464. ))),
  465. }
  466. }
  467. #[tokio::test]
  468. async fn dcr_manage_token_survives_redis_flush() -> TestResult {
  469. // Private-container test: its purpose is a full Redis wipe (`FLUSHALL`),
  470. // which must never touch the shared compose Redis, and DCR state is
  471. // Postgres-backed, so a prefix-scoped `SCAN`+`DEL` would be a vacuous
  472. // no-op. The harness is built via the container-free `harness_with_redis`
  473. // over this container's URL (`_private` keeps it alive for the test).
  474. let container = GenericImage::new("redis", "8-alpine")
  475. .with_exposed_port(6379.tcp())
  476. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  477. .with_cmd(["redis-server", "--appendonly", "yes"])
  478. .start()
  479. .await
  480. .map_err(|error| TestError::Unexpected(format!("private redis: {error}")))?;
  481. let guarded = crate::test_guard::ContainerGuard::new(container);
  482. let port = guarded
  483. .get_host_port_ipv4(6379)
  484. .await
  485. .map_err(|error| TestError::Unexpected(format!("private redis port: {error}")))?;
  486. let _private = guarded;
  487. let mut harness = harness_with_redis(&format!("redis://127.0.0.1:{port}"), "catalog").await?;
  488. let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
  489. {
  490. let (mut stores, ctx) = manage_parts(&mut harness);
  491. read_client(
  492. &mut stores,
  493. &ctx,
  494. &registered.client_id,
  495. Some(&registered.reg_token),
  496. )
  497. .await
  498. .map_err(|error| TestError::Unexpected(format!("pre-flush read failed: {error:?}")))?;
  499. }
  500. let _: () = redis::cmd("FLUSHALL")
  501. .query_async(&mut harness.conn)
  502. .await?;
  503. {
  504. let (mut stores, ctx) = manage_parts(&mut harness);
  505. let reread = read_client(
  506. &mut stores,
  507. &ctx,
  508. &registered.client_id,
  509. Some(&registered.reg_token),
  510. )
  511. .await
  512. .map_err(|error| TestError::Unexpected(format!("post-flush read failed: {error:?}")))?;
  513. if reread.client_id != registered.client_id {
  514. return Err(TestError::Unexpected(format!(
  515. "wrong client after flush: {}",
  516. reread.client_id
  517. )));
  518. }
  519. }
  520. Ok(())
  521. }