e2e_abuse.rs 15 KB

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