e2e_conformance.rs 9.9 KB

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