e2e_mtls_grant.rs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. //! `test(e2e_)` mTLS `tls_client_auth` token-endpoint mint and rejection
  2. //! matrix (plan todo 14).
  3. //!
  4. //! Every case drives the REAL production router
  5. //! ([`d_oauth::routes::build_router`]) in process through
  6. //! [`peer::oneshot_with_peer`], which injects the verified peer chain the TLS
  7. //! accept loop would hand the router. No listener is bound: certificate-chain
  8. //! trust stays covered by the real-handshake `tests/bootstrap.rs` suite, while
  9. //! this suite proves what the router does with an already-verified chain —
  10. //! subject-DN matching, `cnf.x5t#S256` derivation, and the rejection rows.
  11. //!
  12. //! Test names contain `e2e_mtls` so
  13. //! `cargo nextest run -E 'binary(e2e_mtls_grant)'` selects exactly them.
  14. #[allow(dead_code)]
  15. #[path = "e2e/harness.rs"]
  16. mod harness;
  17. #[allow(dead_code)]
  18. #[path = "e2e/peer.rs"]
  19. mod peer;
  20. #[allow(dead_code)]
  21. #[allow(clippy::duplicate_mod)]
  22. #[path = "mtls/certs.rs"]
  23. mod certs;
  24. use axum::{
  25. body::Body,
  26. http::{Method, Request, StatusCode, header},
  27. response::Response,
  28. };
  29. use harness::{
  30. TestError, World, ensure, fail, json_body, open_rate_limit, seed_mtls_client, seed_scope,
  31. };
  32. use peer::{PKI_DN, PKI_THUMBPRINT, oneshot_with_peer, peer_chain};
  33. use rustls::pki_types::CertificateDer;
  34. /// Registered `tls_client_auth` client id.
  35. const CLIENT_ID: &str = "e2e-mtls-pki-client";
  36. /// Drive one form-encoded `POST` through the production router with the given
  37. /// peer evidence and return the status plus the body text.
  38. async fn post_with_peer(
  39. world: &World,
  40. uri: &str,
  41. form: String,
  42. peer: Option<Vec<CertificateDer<'static>>>,
  43. ) -> Result<(StatusCode, String), TestError> {
  44. let request = Request::builder()
  45. .method(Method::POST)
  46. .uri(uri)
  47. .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
  48. .body(Body::from(form))
  49. .map_err(|error| fail(format!("request build failed: {error}")))?;
  50. let response: Response = oneshot_with_peer(world.state.clone(), request, peer).await;
  51. let status = response.status();
  52. let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
  53. .await
  54. .map_err(|error| fail(format!("response body read failed: {error}")))?;
  55. let text = String::from_utf8(bytes.to_vec())
  56. .map_err(|error| fail(format!("response body is not UTF-8: {error}")))?;
  57. Ok((status, text))
  58. }
  59. /// Assert a token-endpoint rejection is `invalid_client` and mints nothing.
  60. fn ensure_invalid_client(status: StatusCode, body: &str) -> Result<(), TestError> {
  61. ensure(
  62. status == StatusCode::BAD_REQUEST,
  63. format!("must be 400 invalid_client, got {status}: {body}"),
  64. )?;
  65. ensure(
  66. json_body(body)?["error"] == "invalid_client",
  67. format!("failure must be invalid_client: {body}"),
  68. )?;
  69. ensure(
  70. !body.contains("access_token"),
  71. "a rejected request must not mint a token",
  72. )?;
  73. Ok(())
  74. }
  75. /// A registered CA-signed subject DN mints a `Bearer` token whose `cnf`
  76. /// carries the leaf thumbprint, and introspection reports that binding.
  77. #[tokio::test]
  78. async fn e2e_mtls_client_credentials_mints_cnf_bound_token() -> Result<(), TestError> {
  79. let world = World::start(open_rate_limit()).await?;
  80. seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
  81. seed_scope(&world.pool, "api:read").await?;
  82. let (status, body) = post_with_peer(
  83. &world,
  84. "/token",
  85. format!("grant_type=client_credentials&client_id={CLIENT_ID}"),
  86. Some(peer_chain().map_err(|error| fail(format!("peer chain fixture: {error}")))?),
  87. )
  88. .await?;
  89. ensure(
  90. status == StatusCode::OK,
  91. format!("mTLS client_credentials must mint, got {status}: {body}"),
  92. )?;
  93. let token = json_body(&body)?;
  94. ensure(
  95. token["token_type"] == "Bearer",
  96. format!("an mTLS-only issuance stays Bearer: {token}"),
  97. )?;
  98. let access_token = token["access_token"]
  99. .as_str()
  100. .filter(|value| !value.is_empty())
  101. .ok_or_else(|| fail("token response must carry an access_token"))?;
  102. let (status, body) = post_with_peer(
  103. &world,
  104. "/introspect",
  105. format!("token={access_token}&client_id={CLIENT_ID}"),
  106. Some(peer_chain().map_err(|error| fail(format!("peer chain fixture: {error}")))?),
  107. )
  108. .await?;
  109. ensure(
  110. status == StatusCode::OK,
  111. format!("introspection must 200, got {status}: {body}"),
  112. )?;
  113. let introspected = json_body(&body)?;
  114. ensure(
  115. introspected["active"] == true,
  116. format!("minted token must introspect active: {introspected}"),
  117. )?;
  118. ensure(
  119. introspected["cnf"]["x5t#S256"] == PKI_THUMBPRINT,
  120. format!("introspection must report the leaf thumbprint: {introspected}"),
  121. )?;
  122. Ok(())
  123. }
  124. /// A verified chain whose subject DN is not the registered one is rejected.
  125. #[tokio::test]
  126. async fn e2e_mtls_unknown_dn_rejected() -> Result<(), TestError> {
  127. let world = World::start(open_rate_limit()).await?;
  128. seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
  129. seed_scope(&world.pool, "api:read").await?;
  130. let unknown = vec![
  131. certs::cert_der(certs::CLIENT_ROGUE_CERT_PEM)
  132. .map_err(|error| fail(format!("rogue cert fixture: {error}")))?,
  133. ];
  134. let (status, body) = post_with_peer(
  135. &world,
  136. "/token",
  137. format!("grant_type=client_credentials&client_id={CLIENT_ID}"),
  138. Some(unknown),
  139. )
  140. .await?;
  141. ensure_invalid_client(status, &body)
  142. }
  143. /// No peer certificate is `invalid_client` for an mTLS-enrolled client.
  144. #[tokio::test]
  145. async fn e2e_mtls_missing_cert_rejected() -> Result<(), TestError> {
  146. let world = World::start(open_rate_limit()).await?;
  147. seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
  148. seed_scope(&world.pool, "api:read").await?;
  149. let (status, body) = post_with_peer(
  150. &world,
  151. "/token",
  152. format!("grant_type=client_credentials&client_id={CLIENT_ID}"),
  153. None,
  154. )
  155. .await?;
  156. ensure_invalid_client(status, &body)
  157. }