e2e_conformance.rs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. //! `test(e2e_)` conformance over the live router: discovery, authorize,
  2. //! PAR, device authorization, introspection, revocation, and served pages.
  3. //!
  4. //! Same harness contract as the sibling suites: the REAL production router
  5. //! over REAL loopback TCP against REAL containers; assertions read only HTTP
  6. //! behavior. The `oauth2` crate builds the authorize URL + PKCE material for
  7. //! the happy path; everything else is hand-rolled `reqwest`.
  8. #[path = "e2e/harness.rs"]
  9. mod harness;
  10. #[path = "e2e/clients.rs"]
  11. mod clients;
  12. use d_oauth::{db::TokenEndpointAuthMethod, protocol::token::ClientKind};
  13. use harness::{
  14. TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
  15. seed_scope,
  16. };
  17. /// Fixture confidential client.
  18. const CLIENT_ID: &str = "e2e-conformance-client";
  19. /// Fixture secret (also the leak probe).
  20. const CLIENT_SECRET: &str = "e2e-conformance-secret-aaaabbbbccccdddd";
  21. /// Public fixture client (direct `/authorize` query flow; confidential
  22. /// clients must use PAR/JAR and page-400 without one).
  23. const PUBLIC_ID: &str = "e2e-conformance-public";
  24. /// Registered loopback redirect.
  25. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  26. /// One world plus the seeded client, scopes, and redirect.
  27. async fn setup() -> Result<World, TestError> {
  28. let world = World::start(open_rate_limit()).await?;
  29. seed_client(
  30. &world.pool,
  31. CLIENT_ID,
  32. Some(CLIENT_SECRET),
  33. &[
  34. "authorization_code",
  35. "refresh_token",
  36. "client_credentials",
  37. "urn:ietf:params:oauth:grant-type:device_code",
  38. ],
  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. /// Discovery conforms: issuer, endpoint URLs, PKCE/DPoP metadata, and the
  59. /// seeded scopes — with no secret material anywhere in the document.
  60. #[tokio::test]
  61. async fn e2e_metadata_conformance() -> Result<(), TestError> {
  62. let world = setup().await?;
  63. let (status, _, body) = world
  64. .get("/.well-known/oauth-authorization-server", &[])
  65. .await?;
  66. ensure(status.is_success(), "metadata must 200")?;
  67. let document = json_body(&body)?;
  68. ensure(
  69. document["issuer"] == "https://auth.example.test",
  70. "issuer must match config",
  71. )?;
  72. for member in [
  73. "authorization_endpoint",
  74. "token_endpoint",
  75. "introspection_endpoint",
  76. "revocation_endpoint",
  77. "pushed_authorization_request_endpoint",
  78. "device_authorization_endpoint",
  79. "code_challenge_methods_supported",
  80. "scopes_supported",
  81. ] {
  82. ensure(
  83. !document[member].is_null(),
  84. format!("metadata must carry {member}"),
  85. )?;
  86. }
  87. let scopes = document["scopes_supported"]
  88. .as_array()
  89. .ok_or_else(|| fail("scopes_supported must be an array"))?;
  90. ensure(
  91. scopes.iter().any(|scope| scope == "read"),
  92. "catalog scope must be advertised",
  93. )?;
  94. ensure(
  95. !body.contains(CLIENT_SECRET),
  96. "metadata must not leak secrets",
  97. )?;
  98. Ok(())
  99. }
  100. /// Happy path via the `oauth2` crate: its authorize URL + PKCE challenge are
  101. /// accepted and the flow 303-redirects into the server login.
  102. #[tokio::test]
  103. async fn e2e_authorize_pkce_happy_path() -> Result<(), TestError> {
  104. let world = setup().await?;
  105. let client = clients::oauth2_client(&world.base, PUBLIC_ID, None)?;
  106. let (challenge, _verifier) = oauth2_pkce();
  107. let (url, state) = client
  108. .authorize_url(oauth2::CsrfToken::new_random)
  109. .add_scope(oauth2::Scope::new("read".to_owned()))
  110. .set_pkce_challenge(challenge)
  111. .url();
  112. let target = format!(
  113. "{}{}",
  114. url.path(),
  115. url
  116. .query()
  117. .map_or(String::new(), |query| format!("?{query}"))
  118. );
  119. let (status, headers, _) = world.get(&target, &[]).await?;
  120. ensure(
  121. status == reqwest::StatusCode::SEE_OTHER,
  122. "valid authorize must 303",
  123. )?;
  124. let location = location_of(&headers)?;
  125. ensure(
  126. location.starts_with("/login?request_id="),
  127. "flow must enter login",
  128. )?;
  129. ensure(!state.secret().is_empty(), "csrf token must be non-empty")?;
  130. Ok(())
  131. }
  132. /// PAR push returns `201 {request_uri, expires_in}` and the authorize
  133. /// endpoint consumes that `request_uri` into the same login redirect.
  134. #[tokio::test]
  135. async fn e2e_par_push_and_authorize() -> Result<(), TestError> {
  136. let world = setup().await?;
  137. let (challenge, _verifier) = oauth2_pkce();
  138. let (status, _, body) = world
  139. .post_form(
  140. "/par",
  141. &[
  142. ("client_id", CLIENT_ID),
  143. ("redirect_uri", REDIRECT_URI),
  144. ("response_type", "code"),
  145. ("scope", "read"),
  146. ("state", "par-state-1"),
  147. ("code_challenge", challenge.as_str()),
  148. ("code_challenge_method", "S256"),
  149. ],
  150. Some((CLIENT_ID, CLIENT_SECRET)),
  151. &[],
  152. )
  153. .await?;
  154. ensure(status == reqwest::StatusCode::CREATED, "PAR push must 201")?;
  155. let pushed = json_body(&body)?;
  156. let request_uri = pushed["request_uri"]
  157. .as_str()
  158. .ok_or_else(|| fail("PAR must return request_uri"))?
  159. .to_owned();
  160. let (status, headers, _) = world
  161. .get(
  162. &format!(
  163. "/authorize?client_id={CLIENT_ID}&request_uri={}",
  164. encoded(&request_uri)
  165. ),
  166. &[],
  167. )
  168. .await?;
  169. ensure(
  170. status == reqwest::StatusCode::SEE_OTHER,
  171. "PAR authorize must 303",
  172. )?;
  173. ensure(
  174. location_of(&headers)?.starts_with("/login?request_id="),
  175. "PAR flow must enter login",
  176. )?;
  177. Ok(())
  178. }
  179. /// Device authorization answers with the RFC 8628 verification payload.
  180. #[tokio::test]
  181. async fn e2e_device_authorization_issues_codes() -> Result<(), TestError> {
  182. let world = setup().await?;
  183. let (status, _, body) = world
  184. .post_form(
  185. "/device_authorization",
  186. &[("client_id", CLIENT_ID), ("scope", "read")],
  187. Some((CLIENT_ID, CLIENT_SECRET)),
  188. &[],
  189. )
  190. .await?;
  191. ensure(status.is_success(), "device authorization must 200")?;
  192. let payload = json_body(&body)?;
  193. for member in ["device_code", "user_code", "verification_uri", "expires_in"] {
  194. ensure(
  195. !payload[member].is_null(),
  196. format!("device response must carry {member}"),
  197. )?;
  198. }
  199. ensure(
  200. !body.contains(CLIENT_SECRET),
  201. "device response must not leak secrets",
  202. )?;
  203. Ok(())
  204. }
  205. /// Introspection reports active tokens with owner + scope, and unknown tokens
  206. /// as inactive — with no secret material in either body.
  207. #[tokio::test]
  208. async fn e2e_introspect_active_and_inactive() -> Result<(), TestError> {
  209. let world = setup().await?;
  210. let minted = world
  211. .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
  212. .await?;
  213. let auth = Some((CLIENT_ID, CLIENT_SECRET));
  214. let (status, _, body) = world
  215. .post_form(
  216. "/introspect",
  217. &[("token", minted.access_token.as_str())],
  218. auth,
  219. &[],
  220. )
  221. .await?;
  222. ensure(status.is_success(), "introspect must 200")?;
  223. let active = json_body(&body)?;
  224. ensure(active["active"] == true, "minted token must be active")?;
  225. ensure(
  226. active["client_id"] == CLIENT_ID,
  227. "introspect must report the owner",
  228. )?;
  229. ensure(
  230. active["scope"] == "read",
  231. "introspect must report the scope",
  232. )?;
  233. ensure(
  234. !body.contains(CLIENT_SECRET),
  235. "introspect must not leak the secret",
  236. )?;
  237. let (status, _, body) = world
  238. .post_form("/introspect", &[("token", "not-a-token")], auth, &[])
  239. .await?;
  240. ensure(status.is_success(), "unknown token still 200")?;
  241. ensure(
  242. json_body(&body)?["active"] == false,
  243. "unknown token must be inactive",
  244. )?;
  245. Ok(())
  246. }
  247. /// Revoking an unknown token is a `200` no-op (no existence oracle).
  248. #[tokio::test]
  249. async fn e2e_revoke_unknown_token_noop() -> Result<(), TestError> {
  250. let world = setup().await?;
  251. let (status, _, _) = world
  252. .post_form(
  253. "/revoke",
  254. &[("token", "never-existed-token")],
  255. Some((CLIENT_ID, CLIENT_SECRET)),
  256. &[],
  257. )
  258. .await?;
  259. ensure(status.is_success(), "unknown-token revoke must 200")?;
  260. Ok(())
  261. }
  262. /// Served pages ride the middleware chain: 200 on dependency-free pages with
  263. /// framing/script lockdown and a request id on every response.
  264. #[tokio::test]
  265. async fn e2e_pages_carry_security_headers() -> Result<(), TestError> {
  266. let world = setup().await?;
  267. let (status, headers, _) = world.get("/device", &[]).await?;
  268. ensure(status.is_success(), "device page must 200")?;
  269. let csp = headers
  270. .get("content-security-policy")
  271. .and_then(|value| value.to_str().ok())
  272. .unwrap_or_default()
  273. .to_owned();
  274. ensure(
  275. csp.contains("frame-ancestors 'none'"),
  276. "pages must deny framing",
  277. )?;
  278. ensure(
  279. headers.contains_key("x-request-id"),
  280. "responses must carry request ids",
  281. )?;
  282. let (status, headers, _) = world.get("/login", &[]).await?;
  283. ensure(
  284. !status.is_server_error(),
  285. "login without context must not 500",
  286. )?;
  287. ensure(
  288. headers.contains_key("x-request-id"),
  289. "error pages keep request ids",
  290. )?;
  291. Ok(())
  292. }
  293. /// PKCE pair from the `oauth2` crate (baseline-flow material only).
  294. fn oauth2_pkce() -> (oauth2::PkceCodeChallenge, oauth2::PkceCodeVerifier) {
  295. oauth2::PkceCodeChallenge::new_random_sha256()
  296. }
  297. /// Percent-encode a value for query embedding.
  298. fn encoded(value: &str) -> String {
  299. let mut out = String::with_capacity(value.len());
  300. for byte in value.bytes() {
  301. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  302. out.push(byte as char);
  303. } else {
  304. out.push_str(&format!("%{byte:02X}"));
  305. }
  306. }
  307. out
  308. }
  309. /// Read the `location` header or fail the test.
  310. fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
  311. headers
  312. .get("location")
  313. .and_then(|value| value.to_str().ok())
  314. .map(str::to_owned)
  315. .ok_or_else(|| fail("response lacks location header"))
  316. }