e2e_logout.rs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. //! `test(e2e_logout)` RP-initiated logout over the live router.
  2. //!
  3. //! `GET /logout` is a read-only confirmation, and only `POST /logout` with the
  4. //! session-bound form token destroys the session. The return target is never
  5. //! caller-supplied: `/authorize` freezes the client's registered post-logout
  6. //! URI in session meta, and `POST /logout` honors it only while the client and
  7. //! that exact URI are still registered.
  8. #[allow(dead_code)]
  9. #[path = "e2e/harness.rs"]
  10. mod harness;
  11. #[path = "e2e/dpop.rs"]
  12. mod dpop;
  13. #[allow(dead_code)]
  14. #[path = "e2e/browser.rs"]
  15. mod browser;
  16. use browser::{
  17. SESSION_COOKIE, begin_browser, begin_browser_with_post_logout, cookie_value, get_consent,
  18. hidden_input, location_of, post_login, totp_now,
  19. };
  20. use harness::{TestError, World, ensure, open_rate_limit};
  21. /// Registered client the logout flow drives.
  22. const CLIENT_ID: &str = "e2e-logout-app";
  23. /// Registered authorize redirect (RFC 8252 loopback exception applies).
  24. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  25. /// Registered post-logout return target.
  26. const POST_LOGOUT_URI: &str = "https://client.example.test/signed-out";
  27. /// Catalog scope the client and the user share.
  28. const SCOPE: &str = "read";
  29. /// A valid `POST /logout` destroys the session server-side, clears the cookie,
  30. /// and redirects to the exact post-logout URI the authorization flow froze.
  31. #[tokio::test]
  32. async fn e2e_logout_post_destroys_session_and_redirects() -> Result<(), TestError> {
  33. let world = World::start(open_rate_limit()).await?;
  34. let browser = begin_browser_with_post_logout(
  35. world,
  36. CLIENT_ID,
  37. REDIRECT_URI,
  38. SCOPE,
  39. "logout-post@example.test",
  40. "correct-horse-28-logout-post",
  41. POST_LOGOUT_URI,
  42. )
  43. .await?;
  44. let (session, cookie) = login(&browser).await?;
  45. let (status, _, body) = browser
  46. .world
  47. .get("/logout", &[("cookie", cookie.as_str())])
  48. .await?;
  49. ensure(
  50. status == reqwest::StatusCode::OK,
  51. format!("GET /logout must render the confirmation: {status} {body}"),
  52. )?;
  53. let logout_csrf = hidden_input(&body, "csrf")?;
  54. let (status, headers, body) = browser
  55. .world
  56. .post_form(
  57. "/logout",
  58. &[("csrf", logout_csrf.as_str())],
  59. None,
  60. &[("cookie", cookie.as_str())],
  61. )
  62. .await?;
  63. ensure(
  64. status == reqwest::StatusCode::SEE_OTHER,
  65. format!("POST /logout must 303 to the bound target: {status} {body}"),
  66. )?;
  67. let location = location_of(&headers)?;
  68. ensure(
  69. location == POST_LOGOUT_URI,
  70. format!("logout must return to the registered post-logout URI, got {location}"),
  71. )?;
  72. let prefix = format!("{SESSION_COOKIE}=");
  73. let cleared = headers.get_all("set-cookie").iter().any(|value| {
  74. value
  75. .to_str()
  76. .ok()
  77. .is_some_and(|text| text.starts_with(prefix.as_str()) && text.contains("Max-Age=0"))
  78. });
  79. ensure(cleared, "POST /logout must expire the session cookie")?;
  80. let (status, headers, _) = get_consent(&browser, &session).await?;
  81. ensure(
  82. status == reqwest::StatusCode::SEE_OTHER,
  83. format!("the destroyed session must not reach consent: {status}"),
  84. )?;
  85. ensure(
  86. location_of(&headers)? == "/login",
  87. "the destroyed session must bounce consent to login",
  88. )?;
  89. Ok(())
  90. }
  91. /// A caller-supplied return target is ignored: only the URI frozen at
  92. /// authorization time is honored, even when the request presents another.
  93. #[tokio::test]
  94. async fn e2e_logout_ignores_caller_supplied_return_target() -> Result<(), TestError> {
  95. let world = World::start(open_rate_limit()).await?;
  96. let browser = begin_browser_with_post_logout(
  97. world,
  98. CLIENT_ID,
  99. REDIRECT_URI,
  100. SCOPE,
  101. "logout-evil@example.test",
  102. "correct-horse-28-logout-evil",
  103. POST_LOGOUT_URI,
  104. )
  105. .await?;
  106. let (_session, cookie) = login(&browser).await?;
  107. let (status, _, body) = browser
  108. .world
  109. .get("/logout", &[("cookie", cookie.as_str())])
  110. .await?;
  111. ensure(
  112. status == reqwest::StatusCode::OK,
  113. format!("GET /logout must render the confirmation: {status} {body}"),
  114. )?;
  115. let logout_csrf = hidden_input(&body, "csrf")?;
  116. let (status, headers, body) = browser
  117. .world
  118. .post_form(
  119. "/logout",
  120. &[
  121. ("csrf", logout_csrf.as_str()),
  122. ("post_logout_redirect_uri", "https://evil.example/steal"),
  123. ("redirect_uri", "https://evil.example/steal"),
  124. ],
  125. None,
  126. &[("cookie", cookie.as_str())],
  127. )
  128. .await?;
  129. ensure(
  130. status == reqwest::StatusCode::SEE_OTHER,
  131. format!("POST /logout must 303 to the bound target: {status} {body}"),
  132. )?;
  133. let location = location_of(&headers)?;
  134. ensure(
  135. location == POST_LOGOUT_URI,
  136. format!("logout must ignore a caller-supplied return target, got {location}"),
  137. )?;
  138. Ok(())
  139. }
  140. /// `GET /logout` only renders the confirmation form; the session survives and
  141. /// still serves the bound consent page.
  142. #[tokio::test]
  143. async fn e2e_logout_get_is_read_only() -> Result<(), TestError> {
  144. let world = World::start(open_rate_limit()).await?;
  145. let browser = begin_browser(
  146. world,
  147. "logout-get@example.test",
  148. "correct-horse-28-logout-get",
  149. )
  150. .await?;
  151. let (session, cookie) = login(&browser).await?;
  152. let (status, _, body) = browser
  153. .world
  154. .get("/logout", &[("cookie", cookie.as_str())])
  155. .await?;
  156. ensure(
  157. status == reqwest::StatusCode::OK,
  158. format!("GET /logout must render the confirmation: {status} {body}"),
  159. )?;
  160. ensure(
  161. body.contains("Sign out"),
  162. "GET /logout must offer the sign-out form",
  163. )?;
  164. let logout_csrf = hidden_input(&body, "csrf")?;
  165. ensure(
  166. !logout_csrf.is_empty(),
  167. "the confirmation form must carry a session-bound token",
  168. )?;
  169. let (status, _, body) = get_consent(&browser, &session).await?;
  170. ensure(
  171. status == reqwest::StatusCode::OK,
  172. format!("a read-only GET must leave the session usable: {status} {body}"),
  173. )?;
  174. Ok(())
  175. }
  176. /// A POST without a valid session-bound token is rejected with
  177. /// `invalid_request`, touches no cookie, and leaves the session usable.
  178. #[tokio::test]
  179. async fn e2e_logout_csrf_mismatch_keeps_session() -> Result<(), TestError> {
  180. let world = World::start(open_rate_limit()).await?;
  181. let browser = begin_browser(
  182. world,
  183. "logout-csrf@example.test",
  184. "correct-horse-28-logout-csrf",
  185. )
  186. .await?;
  187. let (session, cookie) = login(&browser).await?;
  188. for presented in [None, Some("forged-token")] {
  189. let params: Vec<(&str, &str)> = presented.map_or_else(Vec::new, |csrf| vec![("csrf", csrf)]);
  190. let (status, headers, body) = browser
  191. .world
  192. .post_form("/logout", &params, None, &[("cookie", cookie.as_str())])
  193. .await?;
  194. ensure(
  195. status == reqwest::StatusCode::BAD_REQUEST,
  196. format!("a bad logout token must be 400: {status} {body}"),
  197. )?;
  198. ensure(
  199. body.contains("invalid_request"),
  200. "a bad logout token must be invalid_request",
  201. )?;
  202. ensure(
  203. !headers.contains_key("set-cookie"),
  204. "a rejected logout must not touch cookies",
  205. )?;
  206. }
  207. let (status, _, body) = get_consent(&browser, &session).await?;
  208. ensure(
  209. status == reqwest::StatusCode::OK,
  210. format!("a rejected logout must leave the session usable: {status} {body}"),
  211. )?;
  212. Ok(())
  213. }
  214. /// Log in through the driver and return the authenticated session id plus its
  215. /// cookie header value.
  216. async fn login(browser: &browser::Browser) -> Result<(String, String), TestError> {
  217. let code_2fa = totp_now(&browser.totp_secret).await?;
  218. let (status, headers, body) = post_login(browser, &browser.password.clone(), &code_2fa).await?;
  219. ensure(
  220. status == reqwest::StatusCode::SEE_OTHER,
  221. format!("login must 303 to consent: {status} {body}"),
  222. )?;
  223. let session = cookie_value(&headers, SESSION_COOKIE)?;
  224. let cookie = format!("{SESSION_COOKIE}={session}");
  225. Ok((session, cookie))
  226. }