e2e_edge_matrix.rs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. //! `binary(e2e_edge_matrix)` HTTP rejection matrix for DCR, device, and token
  2. //! exchange, plus a `private_key_jwt` mint/replay pair.
  3. //!
  4. //! Every case runs through the REAL production router over a loopback socket
  5. //! against the shared Postgres + Redis. Each row asserts the exact status and
  6. //! OAuth error code and that no token/code/client is issued. DCR rows seed the
  7. //! initial access token through the production keyed-hash path (a plain
  8. //! `sha256_hex` never matches `authorize_caller`).
  9. //!
  10. //! Device rows distinguish `/device` (a consumed user code renders the generic
  11. //! `200` miss) from `/token` (a consumed device code is `400 expired_token`).
  12. #[path = "e2e/harness.rs"]
  13. #[allow(dead_code)]
  14. mod harness;
  15. #[path = "e2e/dpop.rs"]
  16. mod dpop;
  17. #[path = "e2e/browser.rs"]
  18. #[allow(dead_code)]
  19. mod browser;
  20. #[path = "e2e/device.rs"]
  21. #[allow(dead_code)]
  22. mod device;
  23. #[path = "e2e/edge.rs"]
  24. mod edge;
  25. #[path = "e2e/exchange.rs"]
  26. mod exchange;
  27. #[path = "e2e/pkjwt.rs"]
  28. mod pkjwt;
  29. use harness::{TestError, World, ensure, fail, json_body, open_rate_limit};
  30. use pkjwt::{AssertionKey, CLIENT_ID, seed_assertion_client};
  31. use reqwest::StatusCode;
  32. use serde_json::json;
  33. /// `private_key_jwt` assertion-type URN (RFC 7523).
  34. const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
  35. /// One rejection row of the matrix.
  36. #[derive(Clone, Copy)]
  37. enum EdgeCase {
  38. /// DCR wildcard redirect under the default `catalog` policy.
  39. DcrWildcardRedirect,
  40. /// DCR `http://` non-loopback redirect under the `catalog` policy.
  41. DcrPlainHttpNonLoopback,
  42. /// DCR metadata carrying both `jwks` and `jwks_uri`.
  43. DcrJwksAndJwksUri,
  44. /// Device authorization asking beyond the client registration.
  45. DeviceOverRegisteredScope,
  46. /// Consumed user code at `/device` renders the generic 200 miss.
  47. DeviceUserCodeConsumed,
  48. /// Consumed device code at `/token` is `400 expired_token`.
  49. DeviceCodeConsumedAtToken,
  50. /// Token exchange without a `subject_token`.
  51. ExchangeMissingSubjectToken,
  52. /// Token exchange with a repeated `scope` or `grant_type`.
  53. ExchangeDuplicateParameter,
  54. }
  55. impl EdgeCase {
  56. /// Row label used in failure context.
  57. const fn name(self) -> &'static str {
  58. match self {
  59. Self::DcrWildcardRedirect => "dcr wildcard redirect",
  60. Self::DcrPlainHttpNonLoopback => "dcr http non-loopback",
  61. Self::DcrJwksAndJwksUri => "dcr jwks+jwks_uri",
  62. Self::DeviceOverRegisteredScope => "device over-registered scope",
  63. Self::DeviceUserCodeConsumed => "device consumed user_code at /device",
  64. Self::DeviceCodeConsumedAtToken => "device consumed device_code at /token",
  65. Self::ExchangeMissingSubjectToken => "exchange missing subject_token",
  66. Self::ExchangeDuplicateParameter => "exchange duplicate scope/grant_type",
  67. }
  68. }
  69. }
  70. /// The full matrix, one row per case.
  71. const CASES: [EdgeCase; 8] = [
  72. EdgeCase::DcrWildcardRedirect,
  73. EdgeCase::DcrPlainHttpNonLoopback,
  74. EdgeCase::DcrJwksAndJwksUri,
  75. EdgeCase::DeviceOverRegisteredScope,
  76. EdgeCase::DeviceUserCodeConsumed,
  77. EdgeCase::DeviceCodeConsumedAtToken,
  78. EdgeCase::ExchangeMissingSubjectToken,
  79. EdgeCase::ExchangeDuplicateParameter,
  80. ];
  81. /// Every listed edge case rejects at the HTTP layer with its exact code.
  82. #[tokio::test]
  83. async fn e2e_edge_matrix_http_rejections() -> Result<(), TestError> {
  84. for case in CASES {
  85. run(case)
  86. .await
  87. .map_err(|error| fail(format!("row {}: {error}", case.name())))?;
  88. }
  89. Ok(())
  90. }
  91. /// Dispatch one row.
  92. async fn run(case: EdgeCase) -> Result<(), TestError> {
  93. match case {
  94. EdgeCase::DcrWildcardRedirect => {
  95. edge::dcr_rejects(
  96. json!({
  97. "redirect_uris": ["https://*.example.test/cb"],
  98. "token_endpoint_auth_method": "client_secret_basic",
  99. "dpop_bound_access_tokens": true,
  100. }),
  101. "invalid_redirect_uri",
  102. )
  103. .await
  104. }
  105. EdgeCase::DcrPlainHttpNonLoopback => {
  106. edge::dcr_rejects(
  107. json!({
  108. "redirect_uris": ["http://app.example.test/cb"],
  109. "token_endpoint_auth_method": "client_secret_basic",
  110. "dpop_bound_access_tokens": true,
  111. }),
  112. "invalid_redirect_uri",
  113. )
  114. .await
  115. }
  116. EdgeCase::DcrJwksAndJwksUri => {
  117. edge::dcr_rejects(
  118. json!({
  119. "redirect_uris": ["https://app.example.test/cb"],
  120. "token_endpoint_auth_method": "client_secret_basic",
  121. "dpop_bound_access_tokens": true,
  122. "jwks": {"keys": []},
  123. "jwks_uri": "https://app.example.test/jwks.json",
  124. }),
  125. "invalid_client_metadata",
  126. )
  127. .await
  128. }
  129. EdgeCase::DeviceOverRegisteredScope => edge::device_over_registered_scope().await,
  130. EdgeCase::DeviceUserCodeConsumed => edge::device_user_code_consumed().await,
  131. EdgeCase::DeviceCodeConsumedAtToken => edge::device_code_consumed_at_token().await,
  132. EdgeCase::ExchangeMissingSubjectToken => exchange::missing_subject_token().await,
  133. EdgeCase::ExchangeDuplicateParameter => exchange::duplicate_parameter().await,
  134. }
  135. }
  136. /// A valid assertion authenticates and mints; replaying its `jti` is
  137. /// `invalid_client`.
  138. #[tokio::test]
  139. async fn e2e_private_key_jwt_mint_and_replay() -> Result<(), TestError> {
  140. let world = World::start(open_rate_limit()).await?;
  141. let key = AssertionKey::generate("k1")?;
  142. seed_assertion_client(&world, key.jwks()).await?;
  143. let audience = format!("{}/token", world.state.config.issuer);
  144. let assertion = key.mint(CLIENT_ID, &audience, "edge-pkjwt-replay-jti")?;
  145. let params = [
  146. ("grant_type", "client_credentials"),
  147. ("client_id", CLIENT_ID),
  148. ("client_assertion_type", CLIENT_ASSERTION_TYPE),
  149. ("client_assertion", assertion.as_str()),
  150. ];
  151. let (status, _, body) = world.post_form("/token", &params, None, &[]).await?;
  152. ensure(
  153. status == StatusCode::OK,
  154. format!("a valid private_key_jwt must authenticate and mint: {status} {body}"),
  155. )?;
  156. ensure(
  157. json_body(&body)?["access_token"]
  158. .as_str()
  159. .is_some_and(|token| !token.is_empty()),
  160. "the positive leg must issue an access token",
  161. )?;
  162. let (status, _, body) = world.post_form("/token", &params, None, &[]).await?;
  163. edge::ensure_error(status, &body, StatusCode::BAD_REQUEST, "invalid_client")
  164. }