e2e_transport.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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 a raw body and an explicit content type.
  48. async fn post_raw(
  49. world: &World,
  50. path: &str,
  51. content_type: &str,
  52. body: &str,
  53. ) -> Result<(StatusCode, HeaderMap, String), TestError> {
  54. let response = world
  55. .client
  56. .post(format!("{}{path}", world.base))
  57. .header(header::CONTENT_TYPE, content_type)
  58. .body(body.to_owned())
  59. .send()
  60. .await?;
  61. let status = response.status();
  62. let headers = response.headers().clone();
  63. let body = response.text().await?;
  64. Ok((status, headers, body))
  65. }
  66. /// Assert the documented `invalid_request` 400 JSON error, and no token.
  67. fn ensure_invalid_request(status: StatusCode, body: &str) -> Result<(), TestError> {
  68. ensure(
  69. status == StatusCode::BAD_REQUEST,
  70. format!("must be a 400 invalid_request, got {status}: {body}"),
  71. )?;
  72. let error = json_body(body)?;
  73. ensure(
  74. error["error"] == "invalid_request",
  75. format!("must carry invalid_request, got {body}"),
  76. )?;
  77. ensure(
  78. !body.contains("access_token"),
  79. "a rejected request must not carry an access token",
  80. )
  81. }
  82. /// Read one response header or fail the test.
  83. fn header_str(headers: &HeaderMap, name: &str) -> Result<String, TestError> {
  84. headers
  85. .get(name)
  86. .and_then(|value| value.to_str().ok())
  87. .map(str::to_owned)
  88. .ok_or_else(|| fail(format!("response lacks {name} header")))
  89. }
  90. /// An empty form body omits the required `grant_type`: RFC 6749 §5.2 maps a
  91. /// missing required parameter to `invalid_request` 400 (docs/OAUTH.md).
  92. #[tokio::test]
  93. async fn e2e_transport_token_empty_body_is_invalid_request() -> Result<(), TestError> {
  94. let world = setup().await?;
  95. let (status, _, body) = world.post_form("/token", &[], None, &[]).await?;
  96. ensure_invalid_request(status, &body)
  97. }
  98. /// RFC 6749 §5.2 and docs/OAUTH.md: a parameter "more than once" is
  99. /// `invalid_request` 400, whether the duplicate is the grant or the client.
  100. #[tokio::test]
  101. async fn e2e_transport_token_duplicate_params_are_invalid_request() -> Result<(), TestError> {
  102. let world = setup().await?;
  103. let base = [
  104. ("grant_type", "client_credentials"),
  105. ("client_id", CLIENT_ID),
  106. ];
  107. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  108. let duplicated_grant: Vec<(&str, &str)> = vec![
  109. ("grant_type", "client_credentials"),
  110. ("grant_type", "refresh_token"),
  111. ("client_id", CLIENT_ID),
  112. ];
  113. let (status, _, body) = world
  114. .post_form("/token", &duplicated_grant, auth, &[])
  115. .await?;
  116. ensure_invalid_request(status, &body)?;
  117. let duplicated_client = [base[0], base[1], ("client_id", "e2e-transport-other")];
  118. let (status, _, body) = world
  119. .post_form("/token", &duplicated_client, auth, &[])
  120. .await?;
  121. ensure_invalid_request(status, &body)
  122. }
  123. /// A request that is not a form keeps the extractor's 415: it never reaches
  124. /// grant logic and must never 5xx.
  125. #[tokio::test]
  126. async fn e2e_transport_token_wrong_content_type_rejected_cleanly() -> Result<(), TestError> {
  127. let world = setup().await?;
  128. let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}");
  129. for content_type in ["text/plain", "application/json"] {
  130. let (status, _, response_body) = post_raw(&world, "/token", content_type, &body).await?;
  131. ensure(
  132. status == StatusCode::UNSUPPORTED_MEDIA_TYPE,
  133. format!("{content_type} must 415, got {status}: {response_body}"),
  134. )?;
  135. ensure(
  136. !response_body.is_empty(),
  137. format!("{content_type} rejection must carry a clean body"),
  138. )?;
  139. }
  140. Ok(())
  141. }
  142. /// Malformed percent-encoding decodes lossily into an unknown `grant_type`
  143. /// value, so the documented code for "a grant_type outside the five" is
  144. /// `unsupported_grant_type` 400 — never a panic or 5xx.
  145. #[tokio::test]
  146. async fn e2e_transport_token_malformed_encoding_is_clean_4xx() -> Result<(), TestError> {
  147. let world = setup().await?;
  148. for malformed in [
  149. "grant_type=%ZZ&client_id=e2e-transport-client",
  150. "grant_type=%E0%A4%A&client_id=e2e-transport-client",
  151. ] {
  152. let (status, _, body) = post_raw(&world, "/token", FORM, malformed).await?;
  153. ensure(
  154. status == StatusCode::BAD_REQUEST,
  155. format!("malformed encoding must be a clean 400, got {status}: {body}"),
  156. )?;
  157. let error = json_body(&body)?;
  158. ensure(
  159. error["error"] == "unsupported_grant_type",
  160. format!("unknown grant value must be unsupported_grant_type, got {body}"),
  161. )?;
  162. ensure(
  163. !body.contains("access_token"),
  164. "a rejected request must not carry an access token",
  165. )?;
  166. }
  167. Ok(())
  168. }
  169. /// Bodies past the router's configured limit are rejected at the layer
  170. /// before any handler, on both form endpoints, with the tower limit's 413.
  171. #[tokio::test]
  172. async fn e2e_transport_oversized_body_is_rejected() -> Result<(), TestError> {
  173. let world = setup().await?;
  174. let padding = "a".repeat(MAX_BODY_BYTES + 1024);
  175. let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}&padding={padding}");
  176. for path in ["/token", "/par"] {
  177. let (status, _, response_body) = post_raw(&world, path, FORM, &body).await?;
  178. ensure(
  179. status == StatusCode::PAYLOAD_TOO_LARGE,
  180. format!("{path} oversized body must 413, got {status}: {response_body}"),
  181. )?;
  182. }
  183. Ok(())
  184. }
  185. /// PAR mirrors its core rule suite at the HTTP layer: a missing
  186. /// `response_type` and a duplicated one whose surviving value is not `code`
  187. /// are both `invalid_request` 400 after a valid client authentication.
  188. #[tokio::test]
  189. async fn e2e_transport_par_missing_and_duplicate_params() -> Result<(), TestError> {
  190. let world = setup().await?;
  191. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  192. // Missing response_type (the authenticated client_id matches).
  193. let missing = [("client_id", CLIENT_ID), ("redirect_uri", REDIRECT_URI)];
  194. let (status, _, body) = world.post_form("/par", &missing, auth, &[]).await?;
  195. ensure_invalid_request(status, &body)?;
  196. // Duplicated response_type: the surviving (last) value is not `code`.
  197. let duplicated = [
  198. ("client_id", CLIENT_ID),
  199. ("response_type", "code"),
  200. ("response_type", "authorization_code"),
  201. ("redirect_uri", REDIRECT_URI),
  202. ];
  203. let (status, _, body) = world.post_form("/par", &duplicated, auth, &[]).await?;
  204. ensure_invalid_request(status, &body)
  205. }
  206. /// `/register` rejects malformed JSON cleanly before auth, and a valid body
  207. /// without a bearer token is the documented `invalid_client` 401 challenge.
  208. #[tokio::test]
  209. async fn e2e_transport_register_malformed_json_and_missing_auth() -> Result<(), TestError> {
  210. let world = setup().await?;
  211. let (status, _, body) = post_raw(&world, "/register", "application/json", "{not-json").await?;
  212. ensure(
  213. status == StatusCode::BAD_REQUEST,
  214. format!("malformed JSON must be a clean 400, got {status}: {body}"),
  215. )?;
  216. let payload = serde_json::json!({"redirect_uris": ["https://app.example.test/cb"]});
  217. let (status, headers, body) = post_raw(
  218. &world,
  219. "/register",
  220. "application/json",
  221. &payload.to_string(),
  222. )
  223. .await?;
  224. ensure(
  225. status == StatusCode::UNAUTHORIZED,
  226. format!("missing initial access token must 401, got {status}: {body}"),
  227. )?;
  228. ensure(
  229. header_str(&headers, "www-authenticate")?.starts_with("Bearer"),
  230. "401 must carry the Bearer challenge",
  231. )?;
  232. ensure(
  233. json_body(&body)?["error"] == "invalid_client",
  234. format!("missing auth must render invalid_client, got {body}"),
  235. )
  236. }