e2e_abuse.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. //! `test(e2e_)` abuse matrix over the live router: wired-endpoint rows.
  2. //!
  3. //! Every test boots the REAL production router over a REAL loopback socket
  4. //! against the shared Postgres + Redis from `compose.test.yaml` (see
  5. //! `e2e/harness.rs`) and asserts only observable HTTP behavior. `#[test]` names all start with
  6. //! `e2e_` so `cargo nextest run -E 'test(e2e_)'` selects exactly this suite.
  7. //!
  8. //! Rows whose endpoint is wired live here; `/token` grant rows live in
  9. //! `e2e_grants.rs` as real 4xx/success assertions.
  10. #[path = "e2e/harness.rs"]
  11. mod harness;
  12. use d_oauth::db::TokenEndpointAuthMethod;
  13. use d_oauth::protocol::token::ClientKind;
  14. use harness::{
  15. TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
  16. seed_scope,
  17. };
  18. /// Confidential fixture client for the abuse suite.
  19. const CLIENT_ID: &str = "e2e-abuse-client";
  20. /// Fixture secret (also used to prove it never leaks into responses).
  21. const CLIENT_SECRET: &str = "e2e-abuse-secret-9f8b7a6c5d4e3f2a1b0";
  22. /// Public fixture client (direct `/authorize` query flow; confidential
  23. /// clients must use PAR/JAR and page-400 without one).
  24. const PUBLIC_ID: &str = "e2e-abuse-public";
  25. /// Registered loopback redirect (unbound port: registration only).
  26. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  27. /// One world plus the seeded client, scopes, and redirect this suite needs.
  28. async fn setup() -> Result<World, TestError> {
  29. setup_with_rate_limit(open_rate_limit()).await
  30. }
  31. /// One world with a caller-chosen limiter budget.
  32. async fn setup_with_rate_limit(rate_limit: serde_json::Value) -> Result<World, TestError> {
  33. let world = World::start(rate_limit).await?;
  34. seed_client(
  35. &world.pool,
  36. &world.state.keys,
  37. CLIENT_ID,
  38. Some(CLIENT_SECRET),
  39. &["authorization_code", "refresh_token", "client_credentials"],
  40. "read write",
  41. TokenEndpointAuthMethod::ClientSecretBasic,
  42. )
  43. .await?;
  44. seed_scope(&world.pool, "read").await?;
  45. seed_scope(&world.pool, "write").await?;
  46. attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
  47. seed_client(
  48. &world.pool,
  49. &world.state.keys,
  50. PUBLIC_ID,
  51. None,
  52. &["authorization_code", "refresh_token"],
  53. "read write",
  54. TokenEndpointAuthMethod::None,
  55. )
  56. .await?;
  57. attach_redirect(&world.pool, &world.state.keys, PUBLIC_ID, REDIRECT_URI).await?;
  58. Ok(world)
  59. }
  60. /// Unknown client fails closed with a direct 400 page (never a redirect);
  61. /// a validated redirect carries the error (`redirect-with-error`).
  62. #[tokio::test]
  63. async fn e2e_redirect_fail_closed_vs_redirect_with_error() -> Result<(), TestError> {
  64. let world = setup().await?;
  65. let (status, _, _) = world
  66. .get(
  67. "/authorize?client_id=nobody&redirect_uri=https%3A%2F%2Fevil.example%2Fcb",
  68. &[],
  69. )
  70. .await?;
  71. ensure(
  72. status == reqwest::StatusCode::BAD_REQUEST,
  73. "unknown client must page 400",
  74. )?;
  75. let (status, headers, _) = world
  76. .get(
  77. &format!(
  78. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=token",
  79. urlencoding(REDIRECT_URI)
  80. ),
  81. &[],
  82. )
  83. .await?;
  84. ensure(
  85. status == reqwest::StatusCode::FOUND,
  86. "bad response_type must 302",
  87. )?;
  88. let location = header_str(&headers, "location")?;
  89. ensure(
  90. location.contains("error=") && location.starts_with(REDIRECT_URI),
  91. "error must ride the validated redirect",
  92. )?;
  93. ensure(
  94. location.contains("iss=https%3A%2F%2Fauth.example.test"),
  95. "error redirect must carry RFC 9207 iss",
  96. )?;
  97. let (status, _, _) = world
  98. .get(
  99. &format!("/authorize?client_id={PUBLIC_ID}&redirect_uri=https%3A%2F%2Fevil.example%2Fcb"),
  100. &[],
  101. )
  102. .await?;
  103. ensure(
  104. status == reqwest::StatusCode::BAD_REQUEST,
  105. "foreign redirect must page 400",
  106. )?;
  107. Ok(())
  108. }
  109. /// Introspection without credentials is `401 invalid_client` (+ challenge),
  110. /// never debug output and never a successful introspection.
  111. #[tokio::test]
  112. async fn e2e_unauthenticated_introspection_rejected() -> Result<(), TestError> {
  113. let world = setup().await?;
  114. let minted = world
  115. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  116. .await?;
  117. let (status, headers, body) = world
  118. .post_form(
  119. "/introspect",
  120. &[("token", minted.access_token.as_str())],
  121. None,
  122. &[],
  123. )
  124. .await?;
  125. ensure(
  126. status == reqwest::StatusCode::UNAUTHORIZED,
  127. "introspect without auth must 401",
  128. )?;
  129. ensure(
  130. headers.contains_key("www-authenticate"),
  131. "401 must carry WWW-Authenticate",
  132. )?;
  133. ensure(
  134. body.contains("invalid_client"),
  135. "body must be invalid_client",
  136. )?;
  137. let (status, _, _) = world
  138. .post_form("/introspect", &[("token", "bogus")], None, &[])
  139. .await?;
  140. ensure(
  141. status == reqwest::StatusCode::UNAUTHORIZED,
  142. "bogus token without auth must 401",
  143. )?;
  144. Ok(())
  145. }
  146. /// Revocation is idempotent: revoke → 200, revoke again → 200, introspect → inactive.
  147. #[tokio::test]
  148. async fn e2e_revocation_idempotent() -> Result<(), TestError> {
  149. let world = setup().await?;
  150. let minted = world
  151. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  152. .await?;
  153. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  154. let (status, _, _) = world
  155. .post_form(
  156. "/revoke",
  157. &[("token", minted.access_token.as_str())],
  158. auth,
  159. &[],
  160. )
  161. .await?;
  162. ensure(status.is_success(), "first revoke must 200")?;
  163. let (status, _, _) = world
  164. .post_form(
  165. "/revoke",
  166. &[("token", minted.access_token.as_str())],
  167. auth,
  168. &[],
  169. )
  170. .await?;
  171. ensure(status.is_success(), "second revoke must still 200")?;
  172. let (status, _, body) = world
  173. .post_form(
  174. "/introspect",
  175. &[("token", minted.access_token.as_str())],
  176. auth,
  177. &[],
  178. )
  179. .await?;
  180. ensure(status.is_success(), "introspect must 200")?;
  181. let value = json_body(&body)?;
  182. ensure(
  183. value["active"] == false,
  184. "revoked token must introspect inactive",
  185. )?;
  186. let (status, _, _) = world
  187. .post_form("/revoke", &[("token", "unknown")], None, &[])
  188. .await?;
  189. ensure(
  190. status == reqwest::StatusCode::UNAUTHORIZED,
  191. "unauthenticated revoke must 401",
  192. )?;
  193. Ok(())
  194. }
  195. /// Scope beyond the client's registration (and unknown scopes) redirect with
  196. /// `invalid_scope`; the flow never proceeds to login.
  197. #[tokio::test]
  198. async fn e2e_scope_over_request_rejected() -> Result<(), TestError> {
  199. let world = setup().await?;
  200. let (challenge, _verifier) = oauth2_pkce();
  201. let (status, headers, _) = world
  202. .get(
  203. &format!(
  204. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  205. &scope=read%20admin&code_challenge={challenge}&code_challenge_method=S256",
  206. urlencoding(REDIRECT_URI)
  207. ),
  208. &[],
  209. )
  210. .await?;
  211. ensure(
  212. status == reqwest::StatusCode::FOUND,
  213. "over-request must 302",
  214. )?;
  215. let location = header_str(&headers, "location")?;
  216. ensure(
  217. location.contains("error=invalid_scope"),
  218. "over-request must be invalid_scope",
  219. )?;
  220. ensure(
  221. !location.contains("/login"),
  222. "over-request must never reach login",
  223. )?;
  224. Ok(())
  225. }
  226. /// Error redirects echo the request's own `state` (CSRF binding); two
  227. /// concurrent requests never see each other's state.
  228. #[tokio::test]
  229. async fn e2e_state_csrf_echo_isolated() -> Result<(), TestError> {
  230. let world = setup().await?;
  231. let error_url = |state: &str| {
  232. format!(
  233. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=token&state={state}",
  234. urlencoding(REDIRECT_URI)
  235. )
  236. };
  237. let (_, first_headers, _) = world.get(&error_url("aaa-csrf-1"), &[]).await?;
  238. let (_, second_headers, _) = world.get(&error_url("bbb-csrf-2"), &[]).await?;
  239. let first = header_str(&first_headers, "location")?;
  240. let second = header_str(&second_headers, "location")?;
  241. ensure(
  242. first.contains("state=aaa-csrf-1"),
  243. "first error must echo its own state",
  244. )?;
  245. ensure(
  246. second.contains("state=bbb-csrf-2"),
  247. "second error must echo its own state",
  248. )?;
  249. ensure(
  250. first.contains("iss=https%3A%2F%2Fauth.example.test"),
  251. "first error must carry RFC 9207 iss",
  252. )?;
  253. ensure(
  254. second.contains("iss=https%3A%2F%2Fauth.example.test"),
  255. "second error must carry RFC 9207 iss",
  256. )?;
  257. ensure(
  258. !first.contains("bbb-csrf-2") && !second.contains("aaa-csrf-1"),
  259. "states must not cross",
  260. )?;
  261. Ok(())
  262. }
  263. /// A missing `response_type` rides the validated redirect as `invalid_request`
  264. /// with RFC 9207 `iss` (unit coverage exists; this is the e2e wire proof).
  265. #[tokio::test]
  266. async fn e2e_missing_response_type_redirects_with_iss() -> Result<(), TestError> {
  267. let world = setup().await?;
  268. let (status, headers, _) = world
  269. .get(
  270. &format!(
  271. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&state=missing-rt-state",
  272. urlencoding(REDIRECT_URI)
  273. ),
  274. &[],
  275. )
  276. .await?;
  277. ensure(
  278. status == reqwest::StatusCode::FOUND,
  279. "missing response_type must 302",
  280. )?;
  281. let location = header_str(&headers, "location")?;
  282. ensure(
  283. location.starts_with(REDIRECT_URI),
  284. "error must ride the validated redirect",
  285. )?;
  286. ensure(
  287. location.contains("error=invalid_request"),
  288. "missing response_type must be invalid_request",
  289. )?;
  290. ensure(
  291. location.contains("iss=https%3A%2F%2Fauth.example.test"),
  292. "error redirect must carry RFC 9207 iss",
  293. )?;
  294. Ok(())
  295. }
  296. /// A forged `iss` query parameter never steers the flow: the server ignores it
  297. /// and 303-redirects into its own login with its own issuer binding.
  298. #[tokio::test]
  299. async fn e2e_mixup_iss_forged_is_ignored() -> Result<(), TestError> {
  300. let world = setup().await?;
  301. let (challenge, _verifier) = oauth2_pkce();
  302. let (status, headers, _) = world
  303. .get(
  304. &format!(
  305. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  306. &code_challenge={challenge}&code_challenge_method=S256&iss=https%3A%2F%2Fevil.example",
  307. urlencoding(REDIRECT_URI)
  308. ),
  309. &[],
  310. )
  311. .await?;
  312. ensure(
  313. status == reqwest::StatusCode::SEE_OTHER,
  314. "forged iss must not break the flow",
  315. )?;
  316. let location = header_str(&headers, "location")?;
  317. ensure(
  318. location.starts_with("/login?request_id="),
  319. "flow must enter the server login",
  320. )?;
  321. ensure(
  322. !location.contains("evil.example"),
  323. "evil issuer must appear nowhere",
  324. )?;
  325. Ok(())
  326. }
  327. /// A tight per-IP budget yields `429` with `Retry-After` on a protected
  328. /// endpoint; health and metadata stay ungated by construction.
  329. #[tokio::test]
  330. async fn e2e_rate_limit_429_with_retry_after() -> Result<(), TestError> {
  331. let budget = serde_json::json!({
  332. "enabled": true, "window_secs": 60,
  333. "per_ip": 3, "per_client": 100_000,
  334. "login_per_ip": 100_000, "token_per_client": 100_000,
  335. });
  336. let world = setup_with_rate_limit(budget).await?;
  337. let mut limited = 0;
  338. for _ in 0..10 {
  339. let (status, headers, _) = world
  340. .post_form("/introspect", &[("token", "x")], None, &[])
  341. .await?;
  342. if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
  343. limited += 1;
  344. ensure(
  345. headers.contains_key("retry-after"),
  346. "429 must carry Retry-After",
  347. )?;
  348. }
  349. }
  350. ensure(limited > 0, "tight budget must 429 eventually")?;
  351. let (status, _, _) = world.get("/health/live", &[]).await?;
  352. ensure(status.is_success(), "liveness must stay ungated")?;
  353. Ok(())
  354. }
  355. /// Forged proxy client-cert headers are never read: the request is treated as
  356. /// uncertified (401 without credentials), and valid credentials still work
  357. /// with the forged headers present.
  358. #[tokio::test]
  359. async fn e2e_forged_mtls_header_stripped() -> Result<(), TestError> {
  360. let world = setup().await?;
  361. let minted = world
  362. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  363. .await?;
  364. let forged = [
  365. ("x-forwarded-tls-client-cert", "Zm9yZ2Vk"),
  366. ("x-forwarded-tls-client-verify", "SUCCESS"),
  367. ];
  368. let (status, _, body) = world
  369. .post_form(
  370. "/introspect",
  371. &[("token", minted.access_token.as_str())],
  372. None,
  373. &forged,
  374. )
  375. .await?;
  376. ensure(
  377. status == reqwest::StatusCode::UNAUTHORIZED,
  378. "forged cert alone must not authenticate",
  379. )?;
  380. ensure(
  381. !body.contains("active"),
  382. "stripped request must not introspect",
  383. )?;
  384. let (status, _, body) = world
  385. .post_form(
  386. "/introspect",
  387. &[("token", minted.access_token.as_str())],
  388. Some((CLIENT_ID, CLIENT_SECRET)),
  389. &forged,
  390. )
  391. .await?;
  392. ensure(
  393. status.is_success(),
  394. "real credentials must work despite forged headers",
  395. )?;
  396. ensure(
  397. json_body(&body)?["active"] == true,
  398. "token must introspect active",
  399. )?;
  400. Ok(())
  401. }
  402. /// Unauthenticated DCR fails closed: no client is created, no secret
  403. /// material is emitted, and the response is `401 invalid_client` with a
  404. /// Bearer challenge (RFC 7591 §3.2.2).
  405. #[tokio::test]
  406. async fn e2e_unauthenticated_dcr_fail_closed() -> Result<(), TestError> {
  407. let world = setup().await?;
  408. let response = world
  409. .client
  410. .post(format!("{}/register", world.base))
  411. .json(&serde_json::json!({"redirect_uris": ["https://evil.example/cb"]}))
  412. .send()
  413. .await?;
  414. let status = response.status();
  415. let challenge = response
  416. .headers()
  417. .get("www-authenticate")
  418. .and_then(|value| value.to_str().ok())
  419. .unwrap_or_default()
  420. .to_owned();
  421. let body = response.text().await?;
  422. ensure(
  423. status == reqwest::StatusCode::UNAUTHORIZED,
  424. "unauthenticated DCR must 401",
  425. )?;
  426. ensure(
  427. challenge.starts_with("Bearer"),
  428. "DCR 401 must carry a Bearer challenge",
  429. )?;
  430. ensure(
  431. !body.contains(CLIENT_SECRET),
  432. "DCR failure must not leak secrets",
  433. )?;
  434. ensure(
  435. json_body(&body)?["error"] == "invalid_client",
  436. "DCR failure must be invalid_client",
  437. )?;
  438. ensure(
  439. !body.contains("client_secret"),
  440. "DCR failure must not emit client credentials",
  441. )?;
  442. Ok(())
  443. }
  444. /// Disabling a scope in the catalog removes it from discovery and rejects new
  445. /// authorize requests carrying it (`invalid_scope` redirect).
  446. #[tokio::test]
  447. async fn e2e_scope_catalog_disable_stops_authorize() -> Result<(), TestError> {
  448. let world = setup().await?;
  449. let (status, body) = world
  450. .get("/.well-known/oauth-authorization-server", &[])
  451. .await
  452. .map(|(status, _, body)| (status, body))?;
  453. ensure(status.is_success(), "metadata must 200")?;
  454. ensure(
  455. json_body(&body)?["scopes_supported"].as_array().is_some(),
  456. "metadata lists scopes",
  457. )?;
  458. disable_scope(&world.pool, "read").await?;
  459. let (status, body) = world
  460. .get("/.well-known/oauth-authorization-server", &[])
  461. .await
  462. .map(|(status, _, body)| (status, body))?;
  463. ensure(status.is_success(), "metadata must still 200")?;
  464. let scopes = json_body(&body)?["scopes_supported"]
  465. .as_array()
  466. .cloned()
  467. .ok_or_else(|| fail("scopes_supported must be an array"))?;
  468. ensure(
  469. !scopes.iter().any(|scope| scope == "read"),
  470. "disabled scope must leave discovery",
  471. )?;
  472. let (challenge, _verifier) = oauth2_pkce();
  473. let (status, headers, _) = world
  474. .get(
  475. &format!(
  476. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  477. &scope=read&code_challenge={challenge}&code_challenge_method=S256",
  478. urlencoding(REDIRECT_URI)
  479. ),
  480. &[],
  481. )
  482. .await?;
  483. ensure(
  484. status == reqwest::StatusCode::FOUND,
  485. "disabled scope must 302",
  486. )?;
  487. ensure(
  488. header_str(&headers, "location")?.contains("error=invalid_scope"),
  489. "disabled scope must be invalid_scope",
  490. )?;
  491. Ok(())
  492. }
  493. /// Disable one catalog scope (operator action; suite-local: only this suite
  494. /// mutates the catalog directly).
  495. async fn disable_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
  496. d_oauth::db::scope_repo::set_enabled(pool, name, false)
  497. .await
  498. .map_err(|error| fail(format!("scope disable failed: {error}")))?;
  499. Ok(())
  500. }
  501. /// Read one response header or fail the test.
  502. fn header_str(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
  503. headers
  504. .get(name)
  505. .and_then(|value| value.to_str().ok())
  506. .map(str::to_owned)
  507. .ok_or_else(|| fail(format!("response lacks {name} header")))
  508. }
  509. /// Percent-encode a redirect URI for query embedding (unreserved passthrough).
  510. fn urlencoding(value: &str) -> String {
  511. let mut encoded = String::with_capacity(value.len());
  512. for byte in value.bytes() {
  513. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  514. encoded.push(byte as char);
  515. } else {
  516. encoded.push_str(&format!("%{byte:02X}"));
  517. }
  518. }
  519. encoded
  520. }
  521. /// PKCE pair from the `oauth2` crate (baseline-flow material only).
  522. fn oauth2_pkce() -> (String, oauth2::PkceCodeVerifier) {
  523. let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
  524. (challenge.as_str().to_owned(), verifier)
  525. }