e2e_transport.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. //! `test(e2e_transport)` HTTP transport-layer robustness over the live router.
  2. //!
  3. //! The sibling e2e suites drive `/token`, `/par`, and `/register` only with
  4. //! well-formed bodies. This suite pins the transport contract for requests
  5. //! that are malformed before (or independently of) grant logic: empty bodies,
  6. //! missing and duplicated parameters, wrong content types, malformed percent
  7. //! encoding, and bodies past the configured limit. Expected codes come from
  8. //! the documented contract (docs/OAUTH.md error table: "Missing or duplicated
  9. //! parameter" is `invalid_request` 400; docs/SECURITY.md input safety) and
  10. //! from the axum extractor contract for content type — never by copying
  11. //! observed output. No request may panic or answer 5xx.
  12. #[path = "e2e/harness.rs"]
  13. #[allow(dead_code)]
  14. mod harness;
  15. use d_oauth::{db::TokenEndpointAuthMethod, routes::MAX_BODY_BYTES};
  16. use harness::{
  17. TestError, World, ensure, fail, json_body, open_rate_limit, seed_client, seed_scope,
  18. };
  19. use reqwest::{
  20. StatusCode,
  21. header::{self, HeaderMap},
  22. };
  23. /// Fixture confidential client.
  24. const CLIENT_ID: &str = "e2e-transport-client";
  25. /// Fixture client secret.
  26. const CLIENT_SECRET: &str = "e2e-transport-secret-aaaabbbbccccdddd";
  27. /// Form content type the OAuth endpoints require.
  28. const FORM: &str = "application/x-www-form-urlencoded";
  29. /// A redirect URI used where validation stops before the catalog lookup.
  30. const REDIRECT_URI: &str = "https://client.example.test/cb";
  31. /// One world plus a seeded confidential client and enabled scope.
  32. async fn setup() -> Result<World, TestError> {
  33. let world = World::start(open_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",
  41. TokenEndpointAuthMethod::ClientSecretBasic,
  42. )
  43. .await?;
  44. seed_scope(&world.pool, "read").await?;
  45. Ok(world)
  46. }
  47. /// `POST {path}` with raw bytes and an optional explicit content type.
  48. async fn post_body(
  49. world: &World,
  50. path: &str,
  51. content_type: Option<&str>,
  52. body: Vec<u8>,
  53. ) -> Result<(StatusCode, HeaderMap, String), TestError> {
  54. let mut request = world.client.post(format!("{}{path}", world.base));
  55. if let Some(content_type) = content_type {
  56. request = request.header(header::CONTENT_TYPE, content_type);
  57. }
  58. let response = request.body(body).send().await?;
  59. let status = response.status();
  60. let headers = response.headers().clone();
  61. let body = response.text().await?;
  62. Ok((status, headers, body))
  63. }
  64. /// `POST {path}` with a raw body and an explicit content type.
  65. async fn post_raw(
  66. world: &World,
  67. path: &str,
  68. content_type: &str,
  69. body: &str,
  70. ) -> Result<(StatusCode, HeaderMap, String), TestError> {
  71. post_body(world, path, Some(content_type), body.as_bytes().to_vec()).await
  72. }
  73. /// Assert the documented `invalid_request` 400 JSON error, and no token.
  74. fn ensure_invalid_request(status: StatusCode, body: &str) -> Result<(), TestError> {
  75. ensure(
  76. status == StatusCode::BAD_REQUEST,
  77. format!("must be a 400 invalid_request, got {status}: {body}"),
  78. )?;
  79. let error = json_body(body)?;
  80. ensure(
  81. error["error"] == "invalid_request",
  82. format!("must carry invalid_request, got {body}"),
  83. )?;
  84. ensure(
  85. !body.contains("access_token"),
  86. "a rejected request must not carry an access token",
  87. )
  88. }
  89. /// Read one response header or fail the test.
  90. fn header_str(headers: &HeaderMap, name: &str) -> Result<String, TestError> {
  91. headers
  92. .get(name)
  93. .and_then(|value| value.to_str().ok())
  94. .map(str::to_owned)
  95. .ok_or_else(|| fail(format!("response lacks {name} header")))
  96. }
  97. /// An empty form body omits the required `grant_type`: RFC 6749 §5.2 maps a
  98. /// missing required parameter to `invalid_request` 400 (docs/OAUTH.md).
  99. #[tokio::test]
  100. async fn e2e_transport_token_empty_body_is_invalid_request() -> Result<(), TestError> {
  101. let world = setup().await?;
  102. let (status, _, body) = world.post_form("/token", &[], None, &[]).await?;
  103. ensure_invalid_request(status, &body)
  104. }
  105. /// RFC 6749 §5.2 and docs/OAUTH.md: a parameter "more than once" is
  106. /// `invalid_request` 400, whether the duplicate is the grant or the client.
  107. #[tokio::test]
  108. async fn e2e_transport_token_duplicate_params_are_invalid_request() -> Result<(), TestError> {
  109. let world = setup().await?;
  110. let base = [
  111. ("grant_type", "client_credentials"),
  112. ("client_id", CLIENT_ID),
  113. ];
  114. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  115. let duplicated_grant: Vec<(&str, &str)> = vec![
  116. ("grant_type", "client_credentials"),
  117. ("grant_type", "refresh_token"),
  118. ("client_id", CLIENT_ID),
  119. ];
  120. let (status, _, body) = world
  121. .post_form("/token", &duplicated_grant, auth, &[])
  122. .await?;
  123. ensure_invalid_request(status, &body)?;
  124. let duplicated_client = [base[0], base[1], ("client_id", "e2e-transport-other")];
  125. let (status, _, body) = world
  126. .post_form("/token", &duplicated_client, auth, &[])
  127. .await?;
  128. ensure_invalid_request(status, &body)
  129. }
  130. /// A request that is not a form keeps the extractor's 415: it never reaches
  131. /// grant logic and must never 5xx.
  132. #[tokio::test]
  133. async fn e2e_transport_token_wrong_content_type_rejected_cleanly() -> Result<(), TestError> {
  134. let world = setup().await?;
  135. let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}");
  136. for content_type in ["text/plain", "application/json"] {
  137. let (status, _, response_body) = post_raw(&world, "/token", content_type, &body).await?;
  138. ensure(
  139. status == StatusCode::UNSUPPORTED_MEDIA_TYPE,
  140. format!("{content_type} must 415, got {status}: {response_body}"),
  141. )?;
  142. ensure(
  143. !response_body.is_empty(),
  144. format!("{content_type} rejection must carry a clean body"),
  145. )?;
  146. }
  147. Ok(())
  148. }
  149. /// Anti-regression gate for the `Form<TokenForm>` → `Bytes` swap: `/token`
  150. /// keeps the exact pre-swap transport contract. A non-form or missing
  151. /// content type is 415, a body past `MAX_BODY_BYTES` is the layer's 413, a
  152. /// repeated known field is `invalid_request` 400, and a structurally
  153. /// unreadable (non-UTF-8) body is `invalid_request` 400. Percent escapes
  154. /// stay lossy (pre-swap `serde_urlencoded` parity): `%ZZ` reaches grant
  155. /// validation and fails cleanly, never the parse.
  156. #[tokio::test]
  157. async fn token_extractor_preserves_415_and_body_limit() -> Result<(), TestError> {
  158. let world = setup().await?;
  159. let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}");
  160. for content_type in [Some("application/json"), Some("text/plain"), None] {
  161. let (status, _, response_body) =
  162. post_body(&world, "/token", content_type, body.as_bytes().to_vec()).await?;
  163. ensure(
  164. status == StatusCode::UNSUPPORTED_MEDIA_TYPE,
  165. format!("{content_type:?} must 415, got {status}: {response_body}"),
  166. )?;
  167. }
  168. let padding = "a".repeat(MAX_BODY_BYTES + 1024);
  169. let oversized = format!("grant_type=client_credentials&client_id={CLIENT_ID}&padding={padding}");
  170. let (status, _, response_body) =
  171. post_body(&world, "/token", Some(FORM), oversized.into_bytes()).await?;
  172. ensure(
  173. status == StatusCode::PAYLOAD_TOO_LARGE,
  174. format!("oversized token body must 413, got {status}: {response_body}"),
  175. )?;
  176. let duplicate =
  177. format!("grant_type=client_credentials&grant_type=refresh_token&client_id={CLIENT_ID}");
  178. let (status, _, response_body) =
  179. post_body(&world, "/token", Some(FORM), duplicate.into_bytes()).await?;
  180. ensure_invalid_request(status, &response_body)?;
  181. let (status, _, response_body) = post_body(&world, "/token", Some(FORM), vec![0xFF]).await?;
  182. ensure_invalid_request(status, &response_body)?;
  183. let (status, _, response_body) = post_body(
  184. &world,
  185. "/token",
  186. Some(FORM),
  187. b"grant_type=%ZZ&client_id=e2e-transport-client".to_vec(),
  188. )
  189. .await?;
  190. ensure(
  191. status == StatusCode::BAD_REQUEST,
  192. format!("lossy percent escape must stay a clean 400, got {status}: {response_body}"),
  193. )?;
  194. ensure(
  195. !response_body.contains("access_token"),
  196. "a rejected request must not carry an access token",
  197. )
  198. }
  199. /// Malformed percent-encoding decodes lossily into an unknown `grant_type`
  200. /// value, so the documented code for "a grant_type outside the five" is
  201. /// `unsupported_grant_type` 400 — never a panic or 5xx.
  202. #[tokio::test]
  203. async fn e2e_transport_token_malformed_encoding_is_clean_4xx() -> Result<(), TestError> {
  204. let world = setup().await?;
  205. for malformed in [
  206. "grant_type=%ZZ&client_id=e2e-transport-client",
  207. "grant_type=%E0%A4%A&client_id=e2e-transport-client",
  208. ] {
  209. let (status, _, body) = post_raw(&world, "/token", FORM, malformed).await?;
  210. ensure(
  211. status == StatusCode::BAD_REQUEST,
  212. format!("malformed encoding must be a clean 400, got {status}: {body}"),
  213. )?;
  214. let error = json_body(&body)?;
  215. ensure(
  216. error["error"] == "unsupported_grant_type",
  217. format!("unknown grant value must be unsupported_grant_type, got {body}"),
  218. )?;
  219. ensure(
  220. !body.contains("access_token"),
  221. "a rejected request must not carry an access token",
  222. )?;
  223. }
  224. Ok(())
  225. }
  226. /// Bodies past the router's configured limit are rejected at the layer
  227. /// before any handler, on both form endpoints, with the tower limit's 413.
  228. #[tokio::test]
  229. async fn e2e_transport_oversized_body_is_rejected() -> Result<(), TestError> {
  230. let world = setup().await?;
  231. let padding = "a".repeat(MAX_BODY_BYTES + 1024);
  232. let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}&padding={padding}");
  233. for path in ["/token", "/par"] {
  234. let (status, _, response_body) = post_raw(&world, path, FORM, &body).await?;
  235. ensure(
  236. status == StatusCode::PAYLOAD_TOO_LARGE,
  237. format!("{path} oversized body must 413, got {status}: {response_body}"),
  238. )?;
  239. }
  240. Ok(())
  241. }
  242. /// PAR mirrors its core rule suite at the HTTP layer: a missing
  243. /// `response_type` and a duplicated one whose surviving value is not `code`
  244. /// are both `invalid_request` 400 after a valid client authentication.
  245. #[tokio::test]
  246. async fn e2e_transport_par_missing_and_duplicate_params() -> Result<(), TestError> {
  247. let world = setup().await?;
  248. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  249. // Missing response_type (the authenticated client_id matches).
  250. let missing = [("client_id", CLIENT_ID), ("redirect_uri", REDIRECT_URI)];
  251. let (status, _, body) = world.post_form("/par", &missing, auth, &[]).await?;
  252. ensure_invalid_request(status, &body)?;
  253. // Duplicated response_type: the surviving (last) value is not `code`.
  254. let duplicated = [
  255. ("client_id", CLIENT_ID),
  256. ("response_type", "code"),
  257. ("response_type", "authorization_code"),
  258. ("redirect_uri", REDIRECT_URI),
  259. ];
  260. let (status, _, body) = world.post_form("/par", &duplicated, auth, &[]).await?;
  261. ensure_invalid_request(status, &body)
  262. }
  263. /// `/register` rejects malformed JSON cleanly before auth, and a valid body
  264. /// without a bearer token is the documented `invalid_client` 401 challenge.
  265. #[tokio::test]
  266. async fn e2e_transport_register_malformed_json_and_missing_auth() -> Result<(), TestError> {
  267. let world = setup().await?;
  268. let (status, _, body) = post_raw(&world, "/register", "application/json", "{not-json").await?;
  269. ensure(
  270. status == StatusCode::BAD_REQUEST,
  271. format!("malformed JSON must be a clean 400, got {status}: {body}"),
  272. )?;
  273. let payload = serde_json::json!({"redirect_uris": ["https://app.example.test/cb"]});
  274. let (status, headers, body) = post_raw(
  275. &world,
  276. "/register",
  277. "application/json",
  278. &payload.to_string(),
  279. )
  280. .await?;
  281. ensure(
  282. status == StatusCode::UNAUTHORIZED,
  283. format!("missing initial access token must 401, got {status}: {body}"),
  284. )?;
  285. ensure(
  286. header_str(&headers, "www-authenticate")?.starts_with("Bearer"),
  287. "401 must carry the Bearer challenge",
  288. )?;
  289. ensure(
  290. json_body(&body)?["error"] == "invalid_client",
  291. format!("missing auth must render invalid_client, got {body}"),
  292. )
  293. }
  294. /// Authentication precedes catalog validation on `/token`: bad credentials
  295. /// with an unknown `resource` observe `invalid_client` 401 (never
  296. /// `invalid_target`), while the same unknown `resource` with valid
  297. /// credentials still fails `invalid_target` 400 (the check moved, it did
  298. /// not disappear).
  299. #[tokio::test]
  300. async fn e2e_transport_token_auth_runs_before_catalog() -> Result<(), TestError> {
  301. let world = World::start_with(
  302. open_rate_limit(),
  303. serde_json::json!({"resource_indicators_enabled": true}),
  304. )
  305. .await?;
  306. seed_client(
  307. &world.pool,
  308. &world.state.keys,
  309. CLIENT_ID,
  310. Some(CLIENT_SECRET),
  311. &["authorization_code", "refresh_token", "client_credentials"],
  312. "read",
  313. TokenEndpointAuthMethod::ClientSecretBasic,
  314. )
  315. .await?;
  316. seed_scope(&world.pool, "read").await?;
  317. let ghost = "https://ghost.example.test/";
  318. let params = [
  319. ("grant_type", "client_credentials"),
  320. ("client_id", CLIENT_ID),
  321. ("resource", ghost),
  322. ];
  323. let (status, _, body) = world
  324. .post_form("/token", &params, Some((CLIENT_ID, "wrong-secret")), &[])
  325. .await?;
  326. ensure(
  327. status == StatusCode::UNAUTHORIZED,
  328. format!("bad credentials must 401, got {status}: {body}"),
  329. )?;
  330. ensure(
  331. json_body(&body)?["error"] == "invalid_client",
  332. format!("auth must run before the catalog check, got {body}"),
  333. )?;
  334. ensure(
  335. !body.contains("access_token"),
  336. "a rejected request must not carry an access token",
  337. )?;
  338. let (status, _, body) = world
  339. .post_form("/token", &params, Some((CLIENT_ID, CLIENT_SECRET)), &[])
  340. .await?;
  341. ensure(
  342. status == StatusCode::BAD_REQUEST,
  343. format!("unknown resource must 400, got {status}: {body}"),
  344. )?;
  345. ensure(
  346. json_body(&body)?["error"] == "invalid_target",
  347. format!("the catalog check must still run after auth, got {body}"),
  348. )
  349. }