e2e_logout.rs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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. /// `GET /logout` only renders the confirmation form; the session survives and
  92. /// still serves the bound consent page.
  93. #[tokio::test]
  94. async fn e2e_logout_get_is_read_only() -> Result<(), TestError> {
  95. let world = World::start(open_rate_limit()).await?;
  96. let browser = begin_browser(
  97. world,
  98. "logout-get@example.test",
  99. "correct-horse-28-logout-get",
  100. )
  101. .await?;
  102. let (session, cookie) = login(&browser).await?;
  103. let (status, _, body) = browser
  104. .world
  105. .get("/logout", &[("cookie", cookie.as_str())])
  106. .await?;
  107. ensure(
  108. status == reqwest::StatusCode::OK,
  109. format!("GET /logout must render the confirmation: {status} {body}"),
  110. )?;
  111. ensure(
  112. body.contains("Sign out"),
  113. "GET /logout must offer the sign-out form",
  114. )?;
  115. let logout_csrf = hidden_input(&body, "csrf")?;
  116. ensure(
  117. !logout_csrf.is_empty(),
  118. "the confirmation form must carry a session-bound token",
  119. )?;
  120. let (status, _, body) = get_consent(&browser, &session).await?;
  121. ensure(
  122. status == reqwest::StatusCode::OK,
  123. format!("a read-only GET must leave the session usable: {status} {body}"),
  124. )?;
  125. Ok(())
  126. }
  127. /// A POST without a valid session-bound token is rejected with
  128. /// `invalid_request`, touches no cookie, and leaves the session usable.
  129. #[tokio::test]
  130. async fn e2e_logout_csrf_mismatch_keeps_session() -> Result<(), TestError> {
  131. let world = World::start(open_rate_limit()).await?;
  132. let browser = begin_browser(
  133. world,
  134. "logout-csrf@example.test",
  135. "correct-horse-28-logout-csrf",
  136. )
  137. .await?;
  138. let (session, cookie) = login(&browser).await?;
  139. for presented in [None, Some("forged-token")] {
  140. let params: Vec<(&str, &str)> = presented.map_or_else(Vec::new, |csrf| vec![("csrf", csrf)]);
  141. let (status, headers, body) = browser
  142. .world
  143. .post_form("/logout", &params, None, &[("cookie", cookie.as_str())])
  144. .await?;
  145. ensure(
  146. status == reqwest::StatusCode::BAD_REQUEST,
  147. format!("a bad logout token must be 400: {status} {body}"),
  148. )?;
  149. ensure(
  150. body.contains("invalid_request"),
  151. "a bad logout token must be invalid_request",
  152. )?;
  153. ensure(
  154. !headers.contains_key("set-cookie"),
  155. "a rejected logout must not touch cookies",
  156. )?;
  157. }
  158. let (status, _, body) = get_consent(&browser, &session).await?;
  159. ensure(
  160. status == reqwest::StatusCode::OK,
  161. format!("a rejected logout must leave the session usable: {status} {body}"),
  162. )?;
  163. Ok(())
  164. }
  165. /// Log in through the driver and return the authenticated session id plus its
  166. /// cookie header value.
  167. async fn login(browser: &browser::Browser) -> Result<(String, String), TestError> {
  168. let code_2fa = totp_now(&browser.totp_secret).await?;
  169. let (status, headers, body) = post_login(browser, &browser.password.clone(), &code_2fa).await?;
  170. ensure(
  171. status == reqwest::StatusCode::SEE_OTHER,
  172. format!("login must 303 to consent: {status} {body}"),
  173. )?;
  174. let session = cookie_value(&headers, SESSION_COOKIE)?;
  175. let cookie = format!("{SESSION_COOKIE}={session}");
  176. Ok((session, cookie))
  177. }