e2e_abuse.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  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. let (status, _, _) = world
  94. .get(
  95. &format!("/authorize?client_id={PUBLIC_ID}&redirect_uri=https%3A%2F%2Fevil.example%2Fcb"),
  96. &[],
  97. )
  98. .await?;
  99. ensure(
  100. status == reqwest::StatusCode::BAD_REQUEST,
  101. "foreign redirect must page 400",
  102. )?;
  103. Ok(())
  104. }
  105. /// Introspection without credentials is `401 invalid_client` (+ challenge),
  106. /// never debug output and never a successful introspection.
  107. #[tokio::test]
  108. async fn e2e_unauthenticated_introspection_rejected() -> Result<(), TestError> {
  109. let world = setup().await?;
  110. let minted = world
  111. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  112. .await?;
  113. let (status, headers, body) = world
  114. .post_form(
  115. "/introspect",
  116. &[("token", minted.access_token.as_str())],
  117. None,
  118. &[],
  119. )
  120. .await?;
  121. ensure(
  122. status == reqwest::StatusCode::UNAUTHORIZED,
  123. "introspect without auth must 401",
  124. )?;
  125. ensure(
  126. headers.contains_key("www-authenticate"),
  127. "401 must carry WWW-Authenticate",
  128. )?;
  129. ensure(
  130. body.contains("invalid_client"),
  131. "body must be invalid_client",
  132. )?;
  133. let (status, _, _) = world
  134. .post_form("/introspect", &[("token", "bogus")], None, &[])
  135. .await?;
  136. ensure(
  137. status == reqwest::StatusCode::UNAUTHORIZED,
  138. "bogus token without auth must 401",
  139. )?;
  140. Ok(())
  141. }
  142. /// Revocation is idempotent: revoke → 200, revoke again → 200, introspect → inactive.
  143. #[tokio::test]
  144. async fn e2e_revocation_idempotent() -> Result<(), TestError> {
  145. let world = setup().await?;
  146. let minted = world
  147. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  148. .await?;
  149. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  150. let (status, _, _) = world
  151. .post_form(
  152. "/revoke",
  153. &[("token", minted.access_token.as_str())],
  154. auth,
  155. &[],
  156. )
  157. .await?;
  158. ensure(status.is_success(), "first revoke must 200")?;
  159. let (status, _, _) = world
  160. .post_form(
  161. "/revoke",
  162. &[("token", minted.access_token.as_str())],
  163. auth,
  164. &[],
  165. )
  166. .await?;
  167. ensure(status.is_success(), "second revoke must still 200")?;
  168. let (status, _, body) = world
  169. .post_form(
  170. "/introspect",
  171. &[("token", minted.access_token.as_str())],
  172. auth,
  173. &[],
  174. )
  175. .await?;
  176. ensure(status.is_success(), "introspect must 200")?;
  177. let value = json_body(&body)?;
  178. ensure(
  179. value["active"] == false,
  180. "revoked token must introspect inactive",
  181. )?;
  182. let (status, _, _) = world
  183. .post_form("/revoke", &[("token", "unknown")], None, &[])
  184. .await?;
  185. ensure(
  186. status == reqwest::StatusCode::UNAUTHORIZED,
  187. "unauthenticated revoke must 401",
  188. )?;
  189. Ok(())
  190. }
  191. /// Scope beyond the client's registration (and unknown scopes) redirect with
  192. /// `invalid_scope`; the flow never proceeds to login.
  193. #[tokio::test]
  194. async fn e2e_scope_over_request_rejected() -> Result<(), TestError> {
  195. let world = setup().await?;
  196. let (challenge, _verifier) = oauth2_pkce();
  197. let (status, headers, _) = world
  198. .get(
  199. &format!(
  200. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  201. &scope=read%20admin&code_challenge={challenge}&code_challenge_method=S256",
  202. urlencoding(REDIRECT_URI)
  203. ),
  204. &[],
  205. )
  206. .await?;
  207. ensure(
  208. status == reqwest::StatusCode::FOUND,
  209. "over-request must 302",
  210. )?;
  211. let location = header_str(&headers, "location")?;
  212. ensure(
  213. location.contains("error=invalid_scope"),
  214. "over-request must be invalid_scope",
  215. )?;
  216. ensure(
  217. !location.contains("/login"),
  218. "over-request must never reach login",
  219. )?;
  220. Ok(())
  221. }
  222. /// Error redirects echo the request's own `state` (CSRF binding); two
  223. /// concurrent requests never see each other's state.
  224. #[tokio::test]
  225. async fn e2e_state_csrf_echo_isolated() -> Result<(), TestError> {
  226. let world = setup().await?;
  227. let error_url = |state: &str| {
  228. format!(
  229. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=token&state={state}",
  230. urlencoding(REDIRECT_URI)
  231. )
  232. };
  233. let (_, first_headers, _) = world.get(&error_url("aaa-csrf-1"), &[]).await?;
  234. let (_, second_headers, _) = world.get(&error_url("bbb-csrf-2"), &[]).await?;
  235. let first = header_str(&first_headers, "location")?;
  236. let second = header_str(&second_headers, "location")?;
  237. ensure(
  238. first.contains("state=aaa-csrf-1"),
  239. "first error must echo its own state",
  240. )?;
  241. ensure(
  242. second.contains("state=bbb-csrf-2"),
  243. "second error must echo its own state",
  244. )?;
  245. ensure(
  246. !first.contains("bbb-csrf-2") && !second.contains("aaa-csrf-1"),
  247. "states must not cross",
  248. )?;
  249. Ok(())
  250. }
  251. /// A forged `iss` query parameter never steers the flow: the server ignores it
  252. /// and 303-redirects into its own login with its own issuer binding.
  253. #[tokio::test]
  254. async fn e2e_mixup_iss_forged_is_ignored() -> Result<(), TestError> {
  255. let world = setup().await?;
  256. let (challenge, _verifier) = oauth2_pkce();
  257. let (status, headers, _) = world
  258. .get(
  259. &format!(
  260. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  261. &code_challenge={challenge}&code_challenge_method=S256&iss=https%3A%2F%2Fevil.example",
  262. urlencoding(REDIRECT_URI)
  263. ),
  264. &[],
  265. )
  266. .await?;
  267. ensure(
  268. status == reqwest::StatusCode::SEE_OTHER,
  269. "forged iss must not break the flow",
  270. )?;
  271. let location = header_str(&headers, "location")?;
  272. ensure(
  273. location.starts_with("/login?request_id="),
  274. "flow must enter the server login",
  275. )?;
  276. ensure(
  277. !location.contains("evil.example"),
  278. "evil issuer must appear nowhere",
  279. )?;
  280. Ok(())
  281. }
  282. /// A tight per-IP budget yields `429` with `Retry-After` on a protected
  283. /// endpoint; health and metadata stay ungated by construction.
  284. #[tokio::test]
  285. async fn e2e_rate_limit_429_with_retry_after() -> Result<(), TestError> {
  286. let budget = serde_json::json!({
  287. "enabled": true, "window_secs": 60,
  288. "per_ip": 3, "per_client": 100_000,
  289. "login_per_ip": 100_000, "token_per_client": 100_000,
  290. });
  291. let world = setup_with_rate_limit(budget).await?;
  292. let mut limited = 0;
  293. for _ in 0..10 {
  294. let (status, headers, _) = world
  295. .post_form("/introspect", &[("token", "x")], None, &[])
  296. .await?;
  297. if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
  298. limited += 1;
  299. ensure(
  300. headers.contains_key("retry-after"),
  301. "429 must carry Retry-After",
  302. )?;
  303. }
  304. }
  305. ensure(limited > 0, "tight budget must 429 eventually")?;
  306. let (status, _, _) = world.get("/health/live", &[]).await?;
  307. ensure(status.is_success(), "liveness must stay ungated")?;
  308. Ok(())
  309. }
  310. /// Forged proxy client-cert headers are never read: the request is treated as
  311. /// uncertified (401 without credentials), and valid credentials still work
  312. /// with the forged headers present.
  313. #[tokio::test]
  314. async fn e2e_forged_mtls_header_stripped() -> Result<(), TestError> {
  315. let world = setup().await?;
  316. let minted = world
  317. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  318. .await?;
  319. let forged = [
  320. ("x-forwarded-tls-client-cert", "Zm9yZ2Vk"),
  321. ("x-forwarded-tls-client-verify", "SUCCESS"),
  322. ];
  323. let (status, _, body) = world
  324. .post_form(
  325. "/introspect",
  326. &[("token", minted.access_token.as_str())],
  327. None,
  328. &forged,
  329. )
  330. .await?;
  331. ensure(
  332. status == reqwest::StatusCode::UNAUTHORIZED,
  333. "forged cert alone must not authenticate",
  334. )?;
  335. ensure(
  336. !body.contains("active"),
  337. "stripped request must not introspect",
  338. )?;
  339. let (status, _, body) = world
  340. .post_form(
  341. "/introspect",
  342. &[("token", minted.access_token.as_str())],
  343. Some((CLIENT_ID, CLIENT_SECRET)),
  344. &forged,
  345. )
  346. .await?;
  347. ensure(
  348. status.is_success(),
  349. "real credentials must work despite forged headers",
  350. )?;
  351. ensure(
  352. json_body(&body)?["active"] == true,
  353. "token must introspect active",
  354. )?;
  355. Ok(())
  356. }
  357. /// Unauthenticated DCR fails closed: no client is created, no secret
  358. /// material is emitted, and the response is `401 invalid_client` with a
  359. /// Bearer challenge (RFC 7591 §3.2.2).
  360. #[tokio::test]
  361. async fn e2e_unauthenticated_dcr_fail_closed() -> Result<(), TestError> {
  362. let world = setup().await?;
  363. let response = world
  364. .client
  365. .post(format!("{}/register", world.base))
  366. .json(&serde_json::json!({"redirect_uris": ["https://evil.example/cb"]}))
  367. .send()
  368. .await?;
  369. let status = response.status();
  370. let challenge = response
  371. .headers()
  372. .get("www-authenticate")
  373. .and_then(|value| value.to_str().ok())
  374. .unwrap_or_default()
  375. .to_owned();
  376. let body = response.text().await?;
  377. ensure(
  378. status == reqwest::StatusCode::UNAUTHORIZED,
  379. "unauthenticated DCR must 401",
  380. )?;
  381. ensure(
  382. challenge.starts_with("Bearer"),
  383. "DCR 401 must carry a Bearer challenge",
  384. )?;
  385. ensure(
  386. !body.contains(CLIENT_SECRET),
  387. "DCR failure must not leak secrets",
  388. )?;
  389. ensure(
  390. json_body(&body)?["error"] == "invalid_client",
  391. "DCR failure must be invalid_client",
  392. )?;
  393. ensure(
  394. !body.contains("client_secret"),
  395. "DCR failure must not emit client credentials",
  396. )?;
  397. Ok(())
  398. }
  399. /// Disabling a scope in the catalog removes it from discovery and rejects new
  400. /// authorize requests carrying it (`invalid_scope` redirect).
  401. #[tokio::test]
  402. async fn e2e_scope_catalog_disable_stops_authorize() -> Result<(), TestError> {
  403. let world = setup().await?;
  404. let (status, body) = world
  405. .get("/.well-known/oauth-authorization-server", &[])
  406. .await
  407. .map(|(status, _, body)| (status, body))?;
  408. ensure(status.is_success(), "metadata must 200")?;
  409. ensure(
  410. json_body(&body)?["scopes_supported"].as_array().is_some(),
  411. "metadata lists scopes",
  412. )?;
  413. disable_scope(&world.pool, "read").await?;
  414. let (status, body) = world
  415. .get("/.well-known/oauth-authorization-server", &[])
  416. .await
  417. .map(|(status, _, body)| (status, body))?;
  418. ensure(status.is_success(), "metadata must still 200")?;
  419. let scopes = json_body(&body)?["scopes_supported"]
  420. .as_array()
  421. .cloned()
  422. .ok_or_else(|| fail("scopes_supported must be an array"))?;
  423. ensure(
  424. !scopes.iter().any(|scope| scope == "read"),
  425. "disabled scope must leave discovery",
  426. )?;
  427. let (challenge, _verifier) = oauth2_pkce();
  428. let (status, headers, _) = world
  429. .get(
  430. &format!(
  431. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  432. &scope=read&code_challenge={challenge}&code_challenge_method=S256",
  433. urlencoding(REDIRECT_URI)
  434. ),
  435. &[],
  436. )
  437. .await?;
  438. ensure(
  439. status == reqwest::StatusCode::FOUND,
  440. "disabled scope must 302",
  441. )?;
  442. ensure(
  443. header_str(&headers, "location")?.contains("error=invalid_scope"),
  444. "disabled scope must be invalid_scope",
  445. )?;
  446. Ok(())
  447. }
  448. /// Disable one catalog scope (operator action; suite-local: only this suite
  449. /// mutates the catalog directly).
  450. async fn disable_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
  451. d_oauth::db::scope_repo::set_enabled(pool, name, false)
  452. .await
  453. .map_err(|error| fail(format!("scope disable failed: {error}")))?;
  454. Ok(())
  455. }
  456. /// Read one response header or fail the test.
  457. fn header_str(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
  458. headers
  459. .get(name)
  460. .and_then(|value| value.to_str().ok())
  461. .map(str::to_owned)
  462. .ok_or_else(|| fail(format!("response lacks {name} header")))
  463. }
  464. /// Percent-encode a redirect URI for query embedding (unreserved passthrough).
  465. fn urlencoding(value: &str) -> String {
  466. let mut encoded = String::with_capacity(value.len());
  467. for byte in value.bytes() {
  468. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  469. encoded.push(byte as char);
  470. } else {
  471. encoded.push_str(&format!("%{byte:02X}"));
  472. }
  473. }
  474. encoded
  475. }
  476. /// PKCE pair from the `oauth2` crate (baseline-flow material only).
  477. fn oauth2_pkce() -> (String, oauth2::PkceCodeVerifier) {
  478. let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
  479. (challenge.as_str().to_owned(), verifier)
  480. }